Compare commits

...
Author SHA1 Message Date
mind-one003 c72fa450bc 엑셀다운로드 자연수까지 나오도록 array추가 2025-09-23 14:06:45 +09:00
mind-one003 400d6cf732 엑셀 숫자형식 다운로드 추가 2025-09-23 11:07:23 +09:00
swcho 39e2734a49 fix: searchBar.tsx 하수처리시설 검색 에러 수정 2025-09-22 09:54:35 +09:00
mind-one003 d92d4d3949 - 테이블 비율 고정 변수 추가(wideFixed)
- store headerlist 에서 비율 변수 추가(widePercent)
2025-09-17 16:15:11 +09:00
mind-one003 b93efa5503 라인바, 라인차트 x축 라벨 크기 확대 2025-09-11 11:17:37 +09:00
swcho 0b8559891a remove: Gis.js에서 SearchView 컴포넌트 props 삭제 2025-09-09 09:54:40 +09:00
mind-one003 ffa1781005 라인바 차트 좌측 여백 및 라벨 좌측으로 좀 더 이동 2025-09-05 14:21:39 +09:00
mind-one003 b47c0dff2b 라인바 차트 크기 수정, custom left 축 만드는 로직 추가 2025-09-05 13:49:22 +09:00
mind-one003 83f5d878e7 라인바 y축 범위 수정 2025-09-02 16:44:43 +09:00
swcho e815b9662f remove: comboSearchBar index 삭제 2025-09-02 16:00:49 +09:00
swcho 2e6cbad49c remove, refactor:
- comboSearchBar.tsx 삭제
- any 타입 추가
- procSearchBar 시군구, 시설 가나다순 정렬
2025-09-02 15:52:13 +09:00
swcho ede5ef6a21 refactor: GisTree.js 주석 추가 및 최신화 2025-09-02 11:06:37 +09:00
swcho 621d222799 remove: GisTree, Gis 에서 orderedLayers porps 삭제 2025-09-02 10:55:16 +09:00
mind-one003 01df37d0b3 라인차트 호출 시 config color에 dash붙을시 해당색상의 대시라인 생성 2025-09-01 17:01:18 +09:00
swcho 072a5337a3 Merge branch 'iipbs/common/main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/common/main 2025-09-01 13:07:49 +09:00
swcho 2a0f5dce25 remove: 상류관로 보기 함수 삭제 2025-09-01 13:07:46 +09:00
mind-one003 26f2c60f4c 라인바 차트
- 데이터가 NaN이거나 null인 경우 툴팁에서 제외
- NULL인 경우 0으로 라인을 그리는 현상 다음 있는데이터로 라인을 그리도록 수정
2025-08-29 14:59:10 +09:00
swcho 28d0e21815 feat: UiTable에 페이지 번호 props 추가 2025-08-28 17:32:52 +09:00
swcho 49c475709f feat:
- 시설 검색, 다중 시설 검색 시 이전 검색 시설 레이어 삭제 기능 추가
- 시설 위치 정보 없을 때 alert 추가
- 시설 검색 시 해당 시설 아이콘 이미지 표출
2025-08-28 17:11:35 +09:00
mind-one003 31b8152d37 차트 좌우 axis custom 추가 2025-08-27 14:56:02 +09:00
swcho b9f5eb50e5 feat: UiTable 내부 셀 줄바꿈 처리 pre-line 적용 2025-08-26 18:42:36 +09:00
mind-one003 c7772a0a23 빌드에러 수정 2025-08-26 17:50:50 +09:00
mind-one003 392ccefb16 라인바 차트property 추가 (setXAxisYearTick - 날짜 x축일경우 (년도-월)별로 만들어주는 속성) 2025-08-26 17:47:51 +09:00
mind-one003 aeaf507c47 차트property 추가 (setXAxisYearTick - 날짜 x축일경우 (년도-월)별로 만들어주는 속성) 2025-08-26 17:39:07 +09:00
mind-one003 2f4cfa0d78 바차트 툴팁 숫자의 경우 천자리 쉼포 추가 2025-08-26 13:30:24 +09:00
swcho ebfc520d67 feat:
- 여러 개의 검색 결과를 GeoJSON으로 표시 함수 추가
- 피쳐 ID로 지오서버에서 검색해서 여러개 피처의 벡터소스 리턴하는 함수 추가
- 여러 extent 배열을 받아 중앙값 extent를 반환하는 함수 추가
2025-08-20 18:50:08 +09:00
swcho 4df286b086 feat: UiTable 컴포넌트에 headButton props 추가 2025-08-19 15:24:52 +09:00
swcho 262cf21546 remove: 잘못 추가된 물재이용(wr)의 가뭄지도 팝업 페이지 삭제 2025-08-13 17:20:40 +09:00
swcho 056b7bae3c fix: defaultZoom props 수정 2025-08-13 16:31:01 +09:00
swcho 428f617802 fix: defaultZoom props 수정 2025-08-13 16:20:15 +09:00
swcho e797bdbdf7 feat: Gis.js defaultZoom, defaultLonLat props 추가 2025-08-13 16:11:33 +09:00
mind-one005 cb11ec839e UiModal <div> <> 2025-08-13 15:50:23 +09:00
swcho 772dd1c1aa refactor: 시도, 시군구 순서 Custom 정렬 2025-08-13 15:40:07 +09:00
swcho 579e599eb1 feat searchBar 시설명 가나다 순으로 정렬 2025-08-11 18:23:05 +09:00
swcho 3cc8fcb2eb fix: 오타 수정 2025-08-09 23:46:20 +09:00
swcho f644bd6b7e refactor, remove, add: GIS main 브랜치와 동기화 2025-08-09 23:43:37 +09:00
swcho f685884ed7 feat: WR(물재이용) 가뭄지도 팝업 구현 을 위한 Gis 수정 2025-08-09 22:38:02 +09:00
swcho a2af60d52e fix: NoFooterLayout 사용으로 인한 Gis.js의 default height 수정 2025-08-08 13:34:58 +09:00
swcho 395590bbe4 feat: gis.layer.js 최신화 2025-08-06 20:59:56 +09:00
swcho 6c0d65a69a feat: Gis Search 버튼 hover시에 표시되는 title 추가 2025-08-06 09:50:50 +09:00
swcho a6b34780ae feat: 침수심 범례에 진행바(Progress Bar) 추가 2025-08-05 18:11:09 +09:00
swcho a83ea455fb feat: GisTree 레이어 Check 함수 구현 (침수심 범례 기능) 2025-08-05 16:35:03 +09:00
swcho ab63cbe992 feat: Gis.js에 침수심 범례 추가 2025-08-05 10:55:47 +09:00
swcho b2a6285475 feat: UiModal 컴포넌트에 headerTitleClass props 추가 2025-08-04 18:41:35 +09:00
swcho 6808430c9a feat: UITable 컴포넌트의 index props를 통해 외부에서 내부 select idx를 제어할 수 있도록 변경 2025-07-31 16:09:36 +09:00
Your Name dad8b9925e monthpicker enableMonth string[] 파라미터 추가
(선택가능한 월 배열 추가하면됨 ex.['4월', '5월'])
2025-07-28 11:12:27 +09:00
swcho 07c7947da1 fix: gis.layer.js 하수펌프장 CQL 필터 적용 안되는 에러 수정 2025-07-28 10:51:37 +09:00
swcho 35e1203168 fix: monthPicker.tsx 현재 월에 hover 효과가 지속되는 코드 주석 처리 2025-07-28 10:34:35 +09:00
mind-one005 bec3132417 SelectBox labelClass props 추가 2025-07-27 17:13:43 +09:00
Your Name e7a46ac3a2 년월 캘린더 년 readonly 옵션추가 (not necessary) 2025-07-27 14:43:42 +09:00
jyha00 6fb39d6384 UiTable index 수정 2025-07-26 19:25:19 +09:00
jyha00 0ad7d6a2c4 searchBaseForm 버튼 순서 변경 파라미터 추가 2025-07-26 15:58:47 +09:00
mind-one005 8fbecc41a5 LineBarChart2 비율대로 보이도록 2025-07-26 15:03:09 +09:00
leedj312 711adc2e17 폼 오류 수정 2025-07-25 18:45:31 +09:00
JIeunhuh 4920b4c1a2 수요처 추가 2025-07-25 18:35:57 +09:00
leedj312 c4f2af0bfe rgtr nullish 작업 2025-07-25 16:58:52 +09:00
Your Name 190adb6bc1 테이블 헤더 위치 좀더 위로 수정 2025-07-25 14:30:59 +09:00
Your Name 59bccfe1d6 테이블 및 굵은영역 뒷쪽에 글자보이는 문제해결 2025-07-25 14:03:52 +09:00
mind-one005 84ff24c5b5 LineBarChart2 추가 2025-07-25 10:52:47 +09:00
mind-one005 e1b48ab7c8 Merge branch 'iipbs/common/main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/common/main 2025-07-25 10:44:19 +09:00
mind-one005 4c3be448f0 LineBarChar2 추가 2025-07-25 10:42:36 +09:00
jyha00 8ede32f4c4 수요처폼 글씨 크기 변경 2025-07-25 10:41:41 +09:00
swcho 2862fdc9fd fix: Gis.js fetch 로직 에러 수정 2025-07-25 00:50:03 +09:00
swcho a446739432 feat:
- gis.select.js 피처 다중 셀렉트 기능 추가
- Gis.js 상류관로 조회 메서드 추가
- GisLonLat 주석 처리(위경도 표시X)
2025-07-25 00:23:02 +09:00
mind-one005 ea125605d1 Merge branch 'iipbs/common/main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/common/main 2025-07-24 22:01:01 +09:00
mind-one005 8353800dac feat: LineBarChar.tsx Null 체크 추가 2025-07-24 22:00:58 +09:00
29 changed files with 2624 additions and 857 deletions
@@ -19,6 +19,14 @@ const customStyles = {
} as CSSProperties,
};
const fncNumberFormet = (value : string | number) => {
return String(value).includes('.')?
String(parseFloat(value.toString().replace(/,/g, ''))).split('.').map((part, index) =>
index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part
).join('.')
: String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
type BarChartProps = {
chartConfig: ChartConfig | undefined;
chartData: any[] | undefined;
@@ -51,7 +59,7 @@ const CustomTooltip = ({
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
{/* 색상 박스 추가 (범례 색상과 동일) */}
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value}`}</span>
<span className="text-[11px] font-bold">{` ${item.name} : ${fncNumberFormet(item.value)}`}</span>
</div>
))}
</div>
@@ -83,7 +83,6 @@ export default function DoubleBarChart({chartConfig=undefined, chartData=[], cha
.map(key => item[key])
).flat());
};
console.log(chartData);
const barMaxValue = getMaxValue(chartData, 'barVal');
return (
<>
@@ -6,6 +6,8 @@ import {
type ChartConfig
} from "./lib/chart";
import type { ContentType } from "recharts/types/component/DefaultLegendContent";
import type { set } from "date-fns";
import React from "react";
// 툴크 커스텀 css
/** 차트 기본 스타일 */
@@ -29,6 +31,7 @@ type LineBarChartProps = {
yFieldText?: string | undefined;
yFieldText2?: string | undefined;
showLegend?: boolean;
setXAxisYearTick?: boolean;
}
type LegendEntry = {
@@ -86,7 +89,8 @@ export default function LineBarchart({
graphSize = 40,
yFieldText,
yFieldText2,
showLegend = true
showLegend = true,
setXAxisYearTick = false
}: LineBarChartProps) {
if (!chartData || chartData.length === 0) {
return <div>데이터가 없습니다.</div>;
@@ -94,6 +98,7 @@ export default function LineBarchart({
// 데이터의 최대값 계산
const getMaxValue = (data: any[], keyPrefix: string) => {
if (!data || data.length === 0) return 0;
return Math.max(...data.map(item =>
Object.keys(item)
.filter(key => key.includes(keyPrefix))
@@ -104,6 +109,90 @@ export default function LineBarchart({
const lineMaxValue = getMaxValue(chartData, 'lineVal');
const barMaxValue = getMaxValue(chartData, 'barVal');
/**
* 최대값과 구간 수에 따른 범례 만들기
* maxValue : 최대값, segments : 구간 수 (구간수가 5면 눈금은 6)
*/
const makeNiceScale = (maxValue: number, segments = 5) => {
// 최대값이 이상할 경우 0, 0으로 반환
if (!isFinite(maxValue) || maxValue <= 0) {
return { max: 0, ticks: [0] };
}
const exponent = Math.floor(Math.log10(maxValue));
const base = Math.pow(10, exponent);
const fraction = maxValue / base;
let niceFraction: number;
if (fraction <= 1) niceFraction = 1;
else if (fraction <= 2) niceFraction = 2;
else if (fraction <= 3) niceFraction = 3;
else if (fraction <= 4) niceFraction = 4;
else if (fraction <= 5) niceFraction = 5;
else if (fraction <= 6) niceFraction = 6;
else if (fraction <= 7) niceFraction = 7;
else if (fraction <= 8) niceFraction = 8;
else if (fraction <= 9) niceFraction = 9;
else niceFraction = 10;
const niceMax = niceFraction * base; // round up to 1/2/5 * 10^k
const step = niceMax / segments;
const ticks = Array.from({ length: segments + 1 }, (_, i) => Number((i * step).toFixed(10)));
return { max: niceMax, ticks };
};
const leftScale = makeNiceScale(lineMaxValue, 5);
const rightScale = makeNiceScale(barMaxValue, 5);
const getTicks = (data : any []) => {
const monthSet = new Set<string>();
const rtnData : string[] = [];
for(const item of data){
const month = String(item.label).slice(0,7);
if(!monthSet.has(month)){
monthSet.add(month);
rtnData.push(item.label);
}
}
return rtnData;
}
let monthTicks : string[] = [];
if(setXAxisYearTick){
monthTicks = getTicks(chartData);
}
const CustomAxisText : React.FC<{ viewBox: any, fieldText: string, position:string }> = ({ viewBox, fieldText, position }) => {
const { x, y, width, height } = viewBox; // 축 영역
const padding = 5; // 차트 바깥으로 띄우기
if('left' == position) {
return (
<text
x={x - padding} // 맨 좌측 바깥
y={y + height / 2 - 12} // 수직 중앙
textAnchor="middle"
transform={`rotate(-90, ${x - padding}, ${y + height / 2})`}
className="text-[12px] font-bold"
>
{fieldText}
</text>
);
}else{
return (
<text
x={x + height / 2 } // 맨 좌측 바깥
y={y - width + padding } // 수직 중앙
textAnchor="middle"
transform={`rotate(90, ${x}, ${y})`}
className="text-[12px] font-bold"
>
{fieldText}
</text>
);
}
}
return (
<div className={chartClassName}>
{chartTitle && chartTitle !== "" && chartTitle !== "undefined" ? (
@@ -111,33 +200,42 @@ export default function LineBarchart({
{chartTitle}
</div>
) : null}
<div className="h-[calc(100%-42px)] flex justify-center">
<ChartContainer config={chartConfig ?? {}} className="h-full w-full">
<div className="h-[calc(100%-42px)] w-[calc(100%-40px)] ">
<ChartContainer config={chartConfig ?? {}} className="h-full w-[100%] ml-5">
<ComposedChart
data={chartData}
height={graphSize * 7.5}
width={graphSize * 12.5}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
margin={{ top: 5, right: 20, left: 30, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" fontSize="8px" fontWeight="bold"/>
{setXAxisYearTick?
<XAxis dataKey="label" ticks={monthTicks} tickFormatter={(value) => value.slice(0,7)} className="text-[12px] font-bold" /> :
<XAxis dataKey="label" className="text-[12px] font-bold" />
}
{/* 왼쪽 Y축: lineVal */}
{yFieldText && <YAxis
yAxisId="left"
className="text-[12px] font-bold"
tickCount={6}
orientation="left"
domain={[0, Math.ceil(lineMaxValue * 1)]}
label={{ value: yFieldText, angle: -90, position: 'insideLeft', offset: -8 }}
className="text-[12px] font-bold"
tickFormatter={(value) =>
typeof value === "number" ? value.toLocaleString() : value
}
domain={[0, leftScale.max]}
ticks={leftScale.ticks}
orientation="left"
label={(props) => <CustomAxisText {...props} fieldText={yFieldText} position="left" />}
/>}
{/* 오른쪽 Y축: barVal */}
{yFieldText2 && <YAxis
yAxisId="right"
className="text-[12px] font-bold"
tickCount={6}
orientation="right"
domain={[0, Math.ceil(barMaxValue * 1)]}
label={{ value: yFieldText2, angle: -90, position: 'insideRight' }}
orientation="right"
tickFormatter={(value) =>
typeof value === "number" ? value.toLocaleString() : value
}
domain={[0, rightScale.max]}
ticks={rightScale.ticks}
label={(props) => <CustomAxisText {...props} fieldText={yFieldText2} position="right" />}
reversed={true}
/>}
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
@@ -0,0 +1,186 @@
"use client"
import type { CSSProperties } from "react";
import { Bar, CartesianGrid, ComposedChart, Legend, Line, Tooltip, XAxis, YAxis } from "recharts";
import {
ChartContainer,
type ChartConfig
} from "./lib/chart";
import type { ContentType } from "recharts/types/component/DefaultLegendContent";
// 툴크 커스텀 css
/** 차트 기본 스타일 */
const customStyles = {
legendContainer: {
display: "flex",
justifyContent: "center",
gap: "20px",
fontSize: "9px",
fontWeight : "bold",
marginTop: "10px",
} as CSSProperties,
};
type LineBarChartProps = {
chartConfig: ChartConfig | undefined;
chartData: any[] | undefined;
chartTitle: string;
chartClassName?: string;
graphSize?: number;
yFieldText?: string | undefined;
yFieldText2?: string | undefined;
showLegend?: boolean;
}
type LegendEntry = {
value: string;
color: string;
type?: string;
payload?: unknown;
};
type LegendProps = {
payload?: LegendEntry[];
};
// 커스텀 툴팁
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) => {
if (active && payload && payload.length) {
return (
<div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="text-[12px] font-bold m-3">{label}</p>
{payload.map((item, index) => (
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
{/* 색상 박스 추가 (범례 색상과 동일) */}
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
</div>
))}
</div>
);
}
return null;
};
// 커스텀 범례 (글씨 크기 키우기 + 스타일 추가)
const CustomLegend = (props: LegendProps) => {
const { payload } = props;
if (!payload) return null;
return (
<div style={customStyles.legendContainer}>
{payload.map((entry: LegendEntry, index: number) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: entry.color }}></div>
<span className="text-[12px]">{entry.value}</span>
</div>
))}
</div>
);
};
export default function LineBarchart2({
chartConfig,
chartData,
chartTitle,
chartClassName = 'w-full h-full',
graphSize = 40,
yFieldText,
yFieldText2,
showLegend = true
}: LineBarChartProps) {
if (!chartData || chartData.length === 0) {
return <div>데이터가 없습니다.</div>;
}
// 데이터의 최대값 계산
const getMaxValue = (data: any[], keyPrefix: string) => {
if (!data || data.length === 0) return 0;
return Math.max(...data.map(item =>
Object.keys(item)
.filter(key => key.includes(keyPrefix))
.map(key => item[key])
).flat());
};
const lineMaxValue = getMaxValue(chartData, 'lineVal');
const barMaxValue = getMaxValue(chartData, 'barVal');
return (
<div className={chartClassName}>
{chartTitle && chartTitle !== "" && chartTitle !== "undefined" ? (
<div style={{ textAlign: "left", fontSize: "13px", fontWeight: "bold", marginTop:"13px", marginBottom: "10px" }}>
{chartTitle}
</div>
) : null}
<div className="h-[calc(100%-42px)] flex justify-center">
<ChartContainer config={chartConfig ?? {}} className="h-full w-full">
<ComposedChart
data={chartData}
height={graphSize * 7.5}
width={graphSize * 12.5}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" fontSize="8px" fontWeight="bold"/>
{/* 왼쪽 Y축: lineVal */}
{yFieldText && <YAxis
yAxisId="left"
className="text-[12px] font-bold"
tickCount={6}
orientation="left"
domain={[0, Math.ceil(lineMaxValue * 1)*2]}
label={{ value: yFieldText, angle: -90, position: 'insideLeft', offset: -8 }}
/>}
{/* 오른쪽 Y축: barVal */}
{yFieldText2 && <YAxis
yAxisId="right"
className="text-[12px] font-bold"
tickCount={6}
orientation="right"
domain={[0, Math.ceil(barMaxValue * 1)*2]}
label={{ value: yFieldText2, angle: -90, position: 'insideRight' }}
reversed={true}
/>}
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
{showLegend && <Legend content={CustomLegend as ContentType}/>}
{/* 바 차트 (오른쪽 Y축 사용) */}
{Object.keys(chartData[0]).map((key, index) => {
const config = chartConfig?.[key];
if(key.includes("barVal")) {
return (
<Bar
key={`bar-${key}-${index}`}
yAxisId="right"
dataKey={key}
fill={config?.color || "black"}
name={String(config?.label || "Y축")}
maxBarSize={80}
/>
);
}
return null;
})}
{/* 라인 차트 (왼쪽 Y축 사용) */}
{Object.keys(chartData[0]).map((key, index) => {
const config = chartConfig?.[key];
if(key.includes("lineVal")) {
return (
<Line
key={`line-${key}-${index}`}
yAxisId="left"
type="linear"
dataKey={key}
stroke={config?.color || "black"}
strokeWidth={2}
dot={{r: 0}}
name={String(config?.label || "X축")}
/>
);
}
return null;
})}
</ComposedChart>
</ChartContainer>
</div>
</div>
);
}
@@ -15,6 +15,7 @@ type LineChartProps = {
graphSize?: number;
yFieldText?: string;
showLegend?: boolean;
setXAxisYearTick?: boolean;
}
/** 차트 기본 스타일 */
@@ -64,11 +65,11 @@ const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?:
return (
<div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="text-[12px] font-bold m-3">{label}</p>
{payload.map((item, index) => (
{payload.filter(item => item.value != null && String(item.value) != 'NaN' && item.value != undefined).map((item, index) => (
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
{/* 색상 박스 추가 (범례 색상과 동일) */}
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value}`}</span>
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value.toString().replace(',','')}`}</span>
</div>
))}
</div>
@@ -128,6 +129,37 @@ const CustomLegend = (props: LegendProps) => {
);
};
const CustomAxisText : React.FC<{ viewBox: any, fieldText: string, position:string }> = ({ viewBox, fieldText, position }) => {
const { x, y, width, height } = viewBox; // 축 영역
const padding = 5; // 차트 바깥으로 띄우기
if('left' == position) {
return (
<text
x={x - padding} // 맨 좌측 바깥
y={y + height / 2} // 수직 중앙
textAnchor="middle"
transform={`rotate(-90, ${x - padding}, ${y + height / 2})`}
className="text-[12px] font-bold"
>
{fieldText}
</text>
);
}else{
return (
<text
x={x + height / 2 } // 맨 좌측 바깥
y={y - width + padding } // 수직 중앙
textAnchor="middle"
transform={`rotate(90, ${x}, ${y})`}
className="text-[12px] font-bold"
>
{fieldText}
</text>
);
}
}
type DataType = {label: string; val: string};
type ChartDataType = {
@@ -141,7 +173,8 @@ export default function Linechart({
chartClassName = 'w-full h-full',
graphSize = 40,
yFieldText,
showLegend = true
showLegend = true,
setXAxisYearTick = false,
}: LineChartProps) {
if (!chartData || Object.keys(chartData).length === 0) {
return <div>데이터가 없습니다.</div>;
@@ -164,18 +197,24 @@ export default function Linechart({
return acc;
}, []);
// 데이터의 최대값 계산
const getMaxValue = (data: ChartDataType): number => {
return Math.max(...Object.values(data).flatMap(dataset =>
dataset.map(entry => {
const val = parseFloat(entry.val);
return isNaN(val) ? 0 : val;
})
));
};
const lineMaxValue = getMaxValue(chartData);
const getTicks = (data : any []) => {
const monthSet = new Set<string>();
const rtnData : string[] = [];
for(const item of data){
const month = String(item.label).slice(0,7);
if(!monthSet.has(month)){
monthSet.add(month);
rtnData.push(item.label);
}
}
return rtnData;
}
let monthTicks : string[] = [];
if(setXAxisYearTick){
monthTicks = getTicks(mergedData);
}
return (
<div className={chartClassName}>
{chartTitle && chartTitle !== "" && chartTitle !== "undefined" ? (
@@ -192,8 +231,28 @@ export default function Linechart({
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" fontSize="8px" fontWeight="bold" />
<YAxis yAxisId="left" className="text-[12px] font-bold" tickFormatter={(value) => value.toLocaleString()} tickCount={6} orientation="left" label={{ value: yFieldText, className:"text-[12px] font-bold", angle: -90, position: 'insideLeft' }} />
{setXAxisYearTick ?
<XAxis
dataKey="label"
className="text-[12px] font-bold"
ticks={monthTicks}
tickFormatter={(value) => value.slice(0,7)}
/> :
<XAxis
dataKey="label"
className="text-[12px] font-bold"
/>
}
<YAxis
yAxisId="left"
className="text-[12px] font-bold"
orientation="left"
tickFormatter={(value) =>
typeof value === "number" ? value.toLocaleString() : value
}
label={(props) => <CustomAxisText {...props} fieldText={yFieldText} position="left" />}/>
<Tooltip content={<CustomTooltip />} />
{showLegend && <Legend content={CustomLegend as ContentType}/>}
{Object.keys(chartData).map((key) => {
@@ -206,8 +265,10 @@ export default function Linechart({
type="linear"
strokeWidth={2}
dot={false}
connectNulls={true}
name={String(config?.label || "X축")}
stroke={config?.color}
stroke={config?.color?.includes('dash')?config?.color?.replace('dash',''):config?.color}
strokeDasharray={config?.color?.includes("dash")?"4 4":undefined}
/>
);
})}
@@ -2,6 +2,7 @@
import DoubleBarChart from './DoubleBar';
import DoughnutChart from './DoughnutChart';
import LineBarchart from './LineBarChart';
import LineBarchart2 from './LineBarChart2';
import LineChart from './LineChart';
/**차트 FORM */
@@ -14,6 +15,8 @@ const CHART_FORM = {
DN :'daughnut',
/**LINE,BAR차트 */
LINEBAR : 'linebar',
/**LINE,BAR차트 */
LINEBAR2 : 'linebar2',
/**더블바 차트 형태*/
DOUBLEBAR : 'doublebar'
};
@@ -53,6 +56,7 @@ type MainChartProps = {
yFieldText? : string | undefined;
yFieldText2? : string | undefined;
showLegend? : boolean;
setXAxisYearTick ?: boolean;
}
function MainChart(props: MainChartProps) {
@@ -62,7 +66,7 @@ function MainChart(props: MainChartProps) {
);
} else if( props.chartForm == CHART_FORM.LINE ){
return (
<LineChart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} showLegend={props.showLegend}/>
<LineChart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} showLegend={props.showLegend} setXAxisYearTick={props.setXAxisYearTick}/>
);
} else if( props.chartForm == CHART_FORM.DN ){
return (
@@ -70,7 +74,11 @@ function MainChart(props: MainChartProps) {
);
} else if (props.chartForm == CHART_FORM.LINEBAR){
return (
<LineBarchart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} yFieldText2={props.yFieldText2} showLegend={props.showLegend}/>
<LineBarchart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} yFieldText2={props.yFieldText2} showLegend={props.showLegend} setXAxisYearTick={props.setXAxisYearTick}/>
);
} else if (props.chartForm == CHART_FORM.LINEBAR2){
return (
<LineBarchart2 chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} yFieldText2={props.yFieldText2} showLegend={props.showLegend}/>
);
} else if (props.chartForm == CHART_FORM.DOUBLEBAR){
return (
@@ -18,6 +18,8 @@ type MonthPickerProps = {
selectClass?: string;
readOnly?: boolean;
startYear?: number; // 시작 연도 props 추가
yearReadOnly?: boolean;
enableMonth?: string[];
};
// yyyyMM → Date 객체
@@ -39,6 +41,8 @@ export const DateMonthPicker = ({
selectClass,
readOnly = false,
startYear,
yearReadOnly = false,
enableMonth,
}: MonthPickerProps) => {
const [open, setOpen] = useState(false);
@@ -78,6 +82,8 @@ export const DateMonthPicker = ({
currentMonth={parsedDate}
onMonthChange={handleSelect}
startYear={startYear}
yearReadOnly={yearReadOnly}
enableMonth={enableMonth}
/>
</PopoverContent>
</Popover>
@@ -29,9 +29,11 @@ export type MonthPickerProps = {
shouldClosePopover?: boolean
) => void;
startYear?: number;
yearReadOnly?: boolean;
enableMonth?: string[];
};
export function MonthPicker({ currentMonth, onMonthChange, startYear }: MonthPickerProps) {
export function MonthPicker({ currentMonth, onMonthChange, startYear, yearReadOnly, enableMonth }: MonthPickerProps) {
const today = startOfToday();
const currentYear = today.getFullYear();
@@ -60,9 +62,9 @@ const selectedYear = parseInt(yearFromProp);
"inline-flex h-9 w-16 items-center justify-center rounded-md p-0 text-sm font-normal ring-offset-white transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-selected:opacity-100 dark:ring-offset-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50 dark:focus-visible:ring-slate-800",
isEqual(month, currentMonth) &&
"bg-slate-900 text-slate-50 hover:bg-slate-900 hover:text-slate-50 focus:bg-slate-900 focus:text-slate-50 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50 dark:hover:text-slate-900 dark:focus:bg-slate-50 dark:focus:text-slate-900",
!isEqual(month, currentMonth) &&
isEqual(month, getStartOfCurrentMonth()) &&
"bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-50"
// !isEqual(month, currentMonth) &&
// isEqual(month, getStartOfCurrentMonth()) &&
// "bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-50"
);
return (
@@ -73,6 +75,7 @@ const selectedYear = parseInt(yearFromProp);
<div className="flex justify-center pt-1">
<Select
value={yearFromProp}
disabled={yearReadOnly}
onValueChange={(selectedYearStr) => {
const selectedYearNum = parseInt(selectedYearStr);
const newDate = new Date(
@@ -111,7 +114,7 @@ const selectedYear = parseInt(yearFromProp);
<button
name="month"
className={getMonthButtonClass(month)}
disabled={isFuture(month)}
disabled={isFuture(month) || (enableMonth && !enableMonth?.includes(format(month, 'MMM', { locale: ko })))}
role="gridcell"
tabIndex={-1}
type="button"
@@ -9,6 +9,7 @@ type ModalType = {
noHeaderYn?: 'N' | 'Y';
noFooterYn?: 'N' | 'Y';
headerTitle?: string;
headerTitleClass?: string;
footerContent?: React.ReactNode;
useDirectRender?: boolean;
}
@@ -21,6 +22,7 @@ export function UiModal({
noHeaderYn = 'N',
noFooterYn = 'N',
headerTitle,
headerTitleClass,
footerContent,
useDirectRender = false
}: ModalType) {
@@ -31,7 +33,7 @@ export function UiModal({
<DialogContent className={bodyClass}>
{noHeaderYn === 'N' && (
<DialogHeader>
<DialogDescription>
<DialogDescription className={headerTitleClass}>
{headerTitle}
</DialogDescription>
</DialogHeader>
@@ -39,7 +41,7 @@ export function UiModal({
{(open && body) ?
useDirectRender
? body
: <div>{body}</div>
: <>{body}</>
: <></>
}
{noFooterYn === 'N' && (
@@ -6,6 +6,10 @@ type ExcelDownloadProps = {
fileName: string;
sheetNameArray: string[];
headerRange?: string;
decimalArray0? : number[];
decimalArray1? : number[];
decimalArray2? : number[];
decimalArray3? : number[];
}
export const downloadExcel = ({ data, fileName = new Date().getTime().toString() + '.xlsx', sheetNameArray = ["Sheet1"], headerRange='A1:V3'}: ExcelDownloadProps) => {
@@ -15,6 +19,45 @@ export const downloadExcel = ({ data, fileName = new Date().getTime().toString()
excelStyle(XLSX.write(workbook, { bookType: "xlsx", type: "buffer" }), headerRange, fileName);
};
export const downloadExcelFormat = ({ data, fileName = new Date().getTime().toString() + '.xlsx', sheetNameArray = ["Sheet1"], headerRange='A1:V3', decimalArray0=[], decimalArray1=[], decimalArray2=[], decimalArray3=[]}: ExcelDownloadProps) => {
const worksheet = XLSX.utils.json_to_sheet(Array.isArray(data) ? data : [data]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetNameArray[0]);
console.log('decimalArray0 : ', decimalArray0);
const headerEnd = XLSX.utils.decode_range(headerRange).e.r; // 0-based index
const startRow = headerEnd + 1;
// 🔹 전체 범위
const range = XLSX.utils.decode_range(worksheet["!ref"] as string);
const chaneValue = (array: number[], decimal: number) => {
for (let row = startRow; row <= range.e.r; row++) {
if(array.length>0){
for(let idx=0; idx<array.length; idx++){
const cellAddr = XLSX.utils.encode_cell({ r: row, c: Number(array[idx]) });
const cell = worksheet[cellAddr];
if (cell && typeof cell.v === "number") {
if(decimal===1){
cell.z = "#,##0.0"; // 소수점 3자리
}else if(decimal===2){
cell.z = "#,##0.00";
}else if(decimal===3){
cell.z = "#,##0.000";
}else{
cell.z = "#,##0"; // 기본 천단위 콤마
}
}
}
}
}
};
chaneValue(decimalArray0, 0);
chaneValue(decimalArray1, 1);
chaneValue(decimalArray2, 2);
chaneValue(decimalArray3, 3);
excelStyle(XLSX.write(workbook, { bookType: "xlsx", type: "buffer" }), headerRange, fileName);
};
export type SheetParameter = {
data: any[][];
headerNum: number;
@@ -1,448 +1,364 @@
import { Search } from 'lucide-react';
import OlMap from 'ol/Map';
import Overlay from 'ol/Overlay';
import View from 'ol/View';
import { defaults as defaultControls, OverviewMap } from 'ol/control';
import { defaults } from 'ol/interaction';
import * as olProj from 'ol/proj';
import { register } from 'ol/proj/proj4';
import proj4 from 'proj4';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import GisLonLat from './GisLonLat';
import GisMapToggle from './GisMapToggle';
import GisSelect from './GisSelect';
import GisToolBar from './GisToolBar';
import GisTree from './GisTree';
import './lib/css/gis.common.css';
import GisApp from './lib/gis.core';
// import FacilityInfoPopup from './FacilityInfoPopup';
import { Search } from "lucide-react";
import OlMap from "ol/Map";
import View from "ol/View";
import { defaults as defaultControls, OverviewMap } from "ol/control";
import { defaults } from "ol/interaction";
import * as olProj from "ol/proj";
import { register } from "ol/proj/proj4";
import proj4 from "proj4";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import GisMapToggle from "./GisMapToggle";
import GisSelect from "./GisSelect";
import GisToolBar from "./GisToolBar";
import GisTree from "./GisTree";
import "./lib/css/gis.common.css";
import GisApp from "./lib/gis.core";
export const GisContext = createContext({ gisCore: null, gisMap: null, treeData: null, checkedLayerIds: null });
export const GisContext = createContext({
gisCore: null,
gisMap: null,
treeData: null,
checkedLayerIds: null,
});
export const useGisContext = () => useContext(GisContext);
function Gis({
isOpen,
gisMaps,
isSearchModalOpen,
treeData = [],
isSearch = false,
isMiniMap = false,
setIsOpen,
setFeatureData,
featureData,
imgVisble = false,
searchView: SearchView,
detailView: DetailView,
onFeatureSelect,
setIsSearchModalOpen,
onCheckedLayersChange
isOpen, //우측 상세 레이어 팝업
gisMaps,
isSearchModalOpen,
treeData = [],
isSearch = false,
isMiniMap = false,
setIsOpen,
setFeatureData,
featureData,
imgVisble = false,
searchView: SearchView,
detailView: DetailView,
onFeatureSelect,
setIsSearchModalOpen,
onCheckedLayersChange,
searchBar: SearchBar,
waterDepth: WaterDepth,
isPlaying = false,
setIsPlaying = () => { },
targetLayerTypes = [],
checkInterval = 3000,
height: height = "h-[calc(100vh-5rem)]",
onOpenPopup = (data) => { },
droughtDates = { startYmd: '', endYmd: '' },
defaultZoom = 15,
defaultLonLat = [127.4875, 37.4916],
}) {
const mapRef = useRef();
const [mapObject, setMapObject] = useState({});
const [tabVisible, setTabVisible] = useState(true);
const [layerNm, setLayerNm] = useState('')
// const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
// const [featureData, setFeatureData] = useState(null) // 선택된 피쳐 데이터
// const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); // SearchModal 열림 상태
const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []);
const mapInstanceRef = useRef(null);
const checkedLayerIds = useRef([]);
const layerNmRef = useRef('')
const mapRef = useRef();
const [mapObject, setMapObject] = useState({});
const [tabVisible, setTabVisible] = useState(true);
const [layerNm, setLayerNm] = useState("");
const [totalSteps, setTotalSteps] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
// const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
// const [featureData, setFeatureData] = useState(null) // 선택된 피쳐 데이터
// const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); // SearchModal 열림 상태
const gisCore = useMemo(
() => new GisApp.core({ targetId: mapRef.current }),
[],
);
const mapInstanceRef = useRef(null);
const checkedLayerIds = useRef([]);
const layerNmRef = useRef("");
// 팝업 데이터를 저장할 상태와 오버레이 참조를 저장할 ref 추가
// const [facilityInfoPopupData, setFacilityInfoPopupData] = useState([]);
// const facilityOverlaysRef = useRef([]);
// 체크된 레이어 상태 관리
const [checkedLayers, setCheckedLayers] = useState([]);
const [checkedLayersData, setCheckedLayersData] = useState([]);
// 팝업 데이터를 저장할 상태와 오버레이 참조를 저장할 ref 추가
// const [facilityInfoPopupData, setFacilityInfoPopupData] = useState([]);
// const facilityOverlaysRef = useRef([]);
// 체크된 레이어 상태 관리
const [checkedLayers, setCheckedLayers] = useState([]);
const [checkedLayersData, setCheckedLayersData] = useState([]);
let gisMap = null;
const closePopup = () => {
setIsOpen(false); // 팝업 닫기
setFeatureData(null)
};
let gisMap = null;
const closePopup = () => {
setIsOpen(false); // 팝업 닫기
setFeatureData(null);
};
// Feature Click Event
const handleFeatureSelect = useCallback((data) => {
gisMap = mapInstanceRef.current;
// 피처 클릭시
const handleFeatureSelect = useCallback(
(data) => {
console.log("handleFeatureSelect");
onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출
},
[mapInstanceRef],
);
if (!gisMap) {
console.warn('지도가 초기화되지 않았습니다.');
return;
}
//피처로 포커스 이동
const handleFeatureFocus = useCallback(
(layerName, data) => {
console.log("handleFeatureFocus");
console.log(layerName);
console.log(data);
// onFeatureFocus?.(data);
},
[mapInstanceRef],
);
// 항상 같은 select 객체 사용
const select = GisApp.Module.select;
if (!select) return;
// 지도 생성 및 초기화
useEffect(() => {
if (!mapRef.current) return;
//if (mapInstanceRef.current) return; // 이미 생성된 맵이 있다면 중단
// 1. 기존 선택 초기화
select.resetSelectedFeature();
// EPSG:5179 등록
proj4.defs([
[
"EPSG:5179",
"+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs",
],
]);
register(proj4);
// 2. data가 OpenLayers Feature 인스턴스인지 확인
let foundFeature = null;
if (data && typeof data.getGeometry === 'function' && typeof data.setStyle === 'function') {
// 이미 Feature 인스턴스라면 그대로 사용
foundFeature = data;
} else {
// 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용
let featureId = data.fid;
const layerName = layerNmRef.current
if (layerName === '하수재이용시설') {
featureId = data.searchPk;
} else if (layerName === '빗물이용시설' || layerName == '중수도시설') {
featureId = data.bdstInfoSn;
} else if (layerName === '하수처리시설') {
featureId = data.bscFctCd;
} else if (layerName === '온배수시설') {
featureId = data.bdstRuseFcltSn;
} else {
console.warn('해당 레이어에 대한 featureId 매핑 없음', layerNm);
featureId = data.fid || data.id || data.ftrIdn;
}
// gisMap.getLayers().getArray().forEach(layer => {
// const source = layer.getSource?.();
// if (source?.getFeatureById) {
// const feature = source.getFeatureById(String(featureId).trim());
// if (feature) foundFeature = feature;
// }
// });
// const [layerType, id] = String(featureId).split('.');
gisMap.getLayers().getArray().forEach(layer => {
const source = layer.getSource?.();
if (source?.getFeatures) {
const allFeatures = source.getFeatures();
for (const f of allFeatures) {
const id = String(f.getId());
// console.log('id', id, featureId)
if (id && id.split('.')[1] === String(featureId)) {
foundFeature = f;
break;
}
}
}
});
}
let map = mapInstanceRef.current;
// 3. 피처 선택
if (foundFeature) {
let geometry = foundFeature.getGeometry();
if (geometry) {
let extent = geometry.getExtent();
// OpenLayers 지도 객체 생성
if (!map) {
console.log("지도 객체 생성");
map = new OlMap({
layers: [gisCore.baseLayers["base"], gisCore.baseLayers["satellite"]],
renderer: "webgl",
target: mapRef.current,
interactions: defaults({ doubleClickZoom: false }),
controls: defaultControls({
zoom: false,
rotate: false,
attributionOptions: {
collapsible: false,
},
}).extend([]),
view: new View({
projection: GisApp.TO_PROJ,
zoom: defaultZoom ?? gisCore.defaults.zoom,
center: olProj.transform(
defaultLonLat,
GisApp.FROM_PROJ,
GisApp.TO_PROJ,
),
minZoom: gisCore.defaults.minZoom,
maxZoom: gisCore.defaults.maxZoom,
}),
});
} else {
// 📌 재마운트 시 target 없으면 다시 지정
if (!map.getTarget()) {
console.log("재마운트");
map.setTarget(mapRef.current);
}
}
gisMap.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
constrainResolution: true,
nearest: true,
});
}
select.selectFeature(foundFeature, true);
} else {
console.warn('OpenLayers Feature를 찾을 수 없습니다:', data);
}
const select = GisApp.Module.select;
if (select) {
console.log("select 바인딩");
if (select.selectInteraction) {
map.removeInteraction(select.selectInteraction);
}
select.map = map; // map 바인딩
select.setInteraction();
select.pointerMoveEventHandler();
}
setFeatureData(data);
setIsOpen(true);
setIsSearchModalOpen(false);
onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출
}, [mapInstanceRef]);
// 미니맵 추가
if (isMiniMap) {
const overviewMap = new OverviewMap({
layers: [GisApp.Module.core.baseMiniMapLayers["base"]],
view: new View({
projection: GisApp.TO_PROJ,
minZoom: 9,
}),
collapsed: false,
collapseLabel: "\u00BB",
label: "\u00AB",
});
map.addControl(overviewMap);
}
// 지도 생성 및 초기화
useEffect(() => {
if (!mapRef.current) return;
if (mapInstanceRef.current) return; // 이미 생성된 맵이 있다면 중단
gisMaps.current = map;
// EPSG:5179 등록
proj4.defs([
[
'EPSG:5179',
'+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs',
],
]);
register(proj4);
// 스케일 컨트롤 및 베이스 레이어 설정
gisCore.__proto__.map = map;
gisCore.scaleControl();
gisCore.changeBaseLayer("base");
setMapObject({ map: map });
mapInstanceRef.current = map; // 생성 완료 플래그 설정
// OpenLayers 지도 객체 생성
const map = new OlMap({
layers: [gisCore.baseLayers['base'], gisCore.baseLayers['satellite']],
renderer: 'webgl',
target: mapRef.current,
interactions: defaults({ doubleClickZoom: false }),
controls: defaultControls({
zoom: false,
rotate: false,
attributionOptions: {
collapsible: false,
},
}).extend([]),
view: new View({
projection: GisApp.TO_PROJ,
zoom: gisCore.defaults.zoom,
center: olProj.transform(
[127.4875, 37.4916],
GisApp.FROM_PROJ,
GisApp.TO_PROJ
),
minZoom: gisCore.defaults.minZoom,
maxZoom: gisCore.defaults.maxZoom,
}),
});
// 📌 언마운트 시 완전 제거
return () => {
console.log("지도 제거");
map.setTarget(null); // DOM 연결 해제
mapInstanceRef.current = null; // 참조 초기화
};
}, []);
gisMaps.current = map
// treeData 변경 시 레이어 코드 생성
useEffect(() => {
if (!treeData || treeData.length === 0) return;
// 미니맵 추가
if (isMiniMap) {
const overviewMap = new OverviewMap({
layers: [GisApp.Module.core.baseMiniMapLayers['base']],
view: new View({
projection: GisApp.TO_PROJ,
minZoom: 9,
}),
collapsed: false,
collapseLabel: '\u00BB',
label: '\u00AB',
});
map.addControl(overviewMap);
}
// 기존 오버레이 및 레이어 모두 삭제
gisCore.deleteAllOverlay();
gisCore.deleteAllLayer();
// 스케일 컨트롤 및 베이스 레이어 설정
gisCore.__proto__.map = map;
gisCore.scaleControl();
gisCore.changeBaseLayer('base');
setMapObject({ map: map });
mapInstanceRef.current = map; // 생성 완료 플래그 설정
}, []);
// 트리 데이터를 레이어 코드 객체로 변환
const layerCode = gisCore.convertLayerInfosToLayerCode(treeData, "lyrSn");
GisApp.layerCode = layerCode; // 전역 레이어 코드 설정
}, [treeData]);
// treeData 변경 시 레이어 코드 생성
useEffect(() => {
if (!treeData || treeData.length === 0) return;
// droughtDates를 전역(GisApp)에 반영
useEffect(() => {
if (typeof window === "undefined") return;
if (droughtDates?.startYmd && droughtDates?.endYmd) {
GisApp.DROUGHT_DATES = {
startYmd: String(droughtDates.startYmd),
endYmd: String(droughtDates.endYmd),
};
}
}, [droughtDates?.startYmd, droughtDates?.endYmd]);
// 기존 오버레이 및 레이어 모두 삭제
gisCore.deleteAllOverlay();
gisCore.deleteAllLayer();
const getLayerItems = (layerCode) => {
const layerItems = Object.entries(layerCode)
.filter(([key, obj], idx) => obj?.updtPsblYn === "Y")
.map(([key, obj]) => ({
...obj,
name: obj.lyrNm,
label: obj.lyrNm,
value: obj.lyrSn + "",
}))
.sort((a, b) => {
if (a.sortOrdr > b.sortOrdr) return 1;
if (a.sortOrdr < b.sortOrdr) return -1;
return 0;
});
return layerItems;
};
// 트리 데이터를 레이어 코드 객체로 변환
const layerCode = gisCore.convertLayerInfosToLayerCode(treeData, 'lyrSn');
GisApp.layerCode = layerCode; // 전역 레이어 코드 설정
if (mapObject.map) {
try {
gisMap = mapObject.map;
} catch (e) {
console.error("오류 발생:", e); // 시큐어코딩 관련 수정 : 오류 정보를 로그로 출력
}
}
//setTreeData(data);
// 편집 가능한 레이어 목록 생성
const layerItems = getLayerItems(layerCode);
//setEditLayerItems(prev => [...layerItems])
}, [treeData]);
// 체크된 레이어 정보를 처리하는 함수
const handleCheckedLayersChange = useCallback(
(checkedIds, checkedNodesData) => {
setTimeout(() => {
setCheckedLayers(checkedIds);
setCheckedLayersData(checkedNodesData);
onCheckedLayersChange?.(checkedIds, checkedNodesData);
}, 1000);
},
[onCheckedLayersChange],
);
const getLayerItems = (layerCode) => {
const layerItems = Object.entries(layerCode)
.filter(([key, obj], idx) => obj?.updtPsblYn === 'Y')
.map(([key, obj]) => ({
...obj,
name: obj.lyrNm,
label: obj.lyrNm,
value: obj.lyrSn + '',
}))
.sort((a, b) => {
if (a.sortOrdr > b.sortOrdr) return 1;
if (a.sortOrdr < b.sortOrdr) return -1;
return 0;
});
return layerItems;
};
// 진행률 변경 핸들러
const handleProgressChange = useCallback((total, current) => {
setTotalSteps(total);
setCurrentStep(current);
}, []);
if (mapObject.map) {
try {
gisMap = mapObject.map;
} catch (e) {
console.error('오류 발생:', e); // 시큐어코딩 관련 수정 : 오류 정보를 로그로 출력
}
}
return (
<>
<GisContext.Provider
value={{
gisCore,
gisMap: mapInstanceRef.current,
treeData,
checkedLayerIds,
}}
>
<div className={`relative flex ${height}`}>
<div
ref={mapRef}
className="gis-map relative flex-1"
style={{ height: "100%", padding: 0 }}
>
<GisTree
visible={tabVisible}
setVisible={setTabVisible}
onCheckedChange={handleCheckedLayersChange}
onLayerIconClick={(layerNm) => {
layerNmRef.current = layerNm;
setLayerNm(layerNm);
setIsOpen(true);
const dummyFeature = {
getProperties: () => ({}), // 최소 구조
};
console.log(layerNm)
onOpenPopup(layerNm)
// onFeatureSelect(dummyFeature);
}}
imageVisible={imgVisble}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
targetLayerTypes={targetLayerTypes}
checkInterval={checkInterval}
onProgressChange={handleProgressChange}
/>
// // 데이터 로드 및 팝업 생성 로직
// useEffect(() => {
// if (!mapInstanceRef.current) return; // 맵이 생성되지 않았으면 실행 안 함
{typeof WaterDepth === "function" && (
<div className={`absolute bottom-4 z-40 ${tabVisible ? 'left-[260px]' : 'left-4'}`}>
<WaterDepth
onClose={() => setIsOpen(false)}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
totalSteps={totalSteps}
currentStep={currentStep}
/>
</div>
)}
// // 1. 팝업 데이터 가져오기 (feature.js의 getData와 유사)
// const fetchPopupData = async () => {
// // 실제 API 호출 로직으로 대체해야 합니다.
// // 예시: const response = await yourApiService.getFcltyWtCnt();
// // setFacilityInfoPopupData(response.data);
{/* DetailView가 존재할 때만 렌더링 isSearchModalOpen이 true면 visible=false로 팝업 숨김 */}
{isOpen && !isSearchModalOpen && (
<DetailView
featureData={featureData || { getProperties: () => ({}) }}
seletectedLayerNm={layerNm}
onClose={closePopup} // 닫기 콜백 전달
onFeatureSelect={handleFeatureSelect}
/>
)}
{/* SearchView가 존재할 때만 렌더링 */}
{SearchView && (
<SearchView />
)}
{/* 시도/시군구 조회 영역 */}
{SearchBar && (
<div className="absolute left-1/2 top-4 z-30 w-max -translate-x-1/2 rounded">
{SearchBar}
</div>
)}
<GisToolBar />
<GisMapToggle />
<GisSelect onFeatureSelect={handleFeatureSelect} />
</div>
</div>
// // 임시 목업 데이터
// const mockData = [
// { ftrIdn: 1, ftrCde: 'SA206', bplcNm: '처리장A', fcltyUseYn: 'Y', x: 127.005, y: 37.282, aCnt: 10, cCnt: 5, eCnt: 3, mCnt: 8, iCnt: 2 },
// { ftrIdn: 2, ftrCde: 'SA112', bplcNm: '처리장B', fcltyUseYn: 'N', x: 127.010, y: 37.285, aCnt: 8, cCnt: 4, eCnt: 2, mCnt: 6, iCnt: 1 }
// ];
// setFacilityInfoPopupData(mockData);
// };
// fetchPopupData();
// }, [mapInstanceRef.current]); // 맵 인스턴스가 생성된 후 한 번만 실행
// // 데이터가 변경되면 오버레이를 생성/업데이트하는 로직
// useEffect(() => {
// const map = mapInstanceRef.current;
// if (!map || facilityInfoPopupData.length === 0) return;
// // 기존 오버레이 정리
// facilityOverlaysRef.current.forEach(overlay => map.removeOverlay(overlay));
// facilityOverlaysRef.current = [];
// facilityInfoPopupData.forEach(data => {
// const popupId = `${data.ftrIdn}_${data.ftrCde}`;
// // 팝업을 담을 div 엘리먼트 생성
// const popupElement = document.createElement('div');
// popupElement.id = popupId;
// // OpenLayers Overlay 생성
// const position = olProj.transform([Number(data.x), Number(data.y)], GisApp.FROM_PROJ, GisApp.TO_PROJ);
// const overlay = new Overlay({
// element: popupElement,
// position: position,
// offset: [-75, -210], // 팝업 너비의 절반, 높이만큼 오프셋 조정
// positioning: 'bottom-center',
// className: 'facility-popup-overlay' // 필요시 공통 클래스 추가
// });
// map.addOverlay(overlay);
// facilityOverlaysRef.current.push(overlay);
// // React 컴포넌트를 popupElement에 렌더링
// const root = createRoot(popupElement);
// root.render(<FacilityInfoPopup data={data} />);
// });
// // 초기 가시성 업데이트
// updatePopupVisibility();
// }, [facilityInfoPopupData]); // 팝업 데이터가 변경될 때마다 실행
// // 레이어 가시성에 따라 팝업 가시성을 제어하는 함수
// const updatePopupVisibility = useCallback(() => {
// const map = mapInstanceRef.current;
// if (!map) return;
// const visibleLayers = new Set();
// map.getLayers().forEach(layer => {
// if (layer.getVisible()) {
// // feature.js의 로직처럼 typeName이나 ftrCde를 사용해 필터링
// const typeName = layer.get('typeName');
// if (typeName === 'VW_WTL_FCLTY_AS') { // 예시 레이어 이름
// visibleLayers.add(layer.get('ftrCde'));
// }
// }
// });
// facilityOverlaysRef.current.forEach(overlay => {
// const element = overlay.getElement();
// const ftrCde = element.querySelector('table')?.classList[0]; // FacilityPopup의 클래스에서 ftrCde 가져오기
// if (visibleLayers.has(ftrCde)) {
// element.style.display = '';
// } else {
// element.style.display = 'none';
// }
// });
// }, []);
// GisTree에서 체크 상태가 변경될 때 이 함수를 호출하도록 연결해야 합니다.
// 예시: GisTree 컴포넌트에 onCheck 콜백을 추가하고, 그 콜백에서 이 함수를 호출
// useEffect(() => {
// // 레이어 가시성이 변경될 때마다 팝업 가시성 업데이트
// const map = mapInstanceRef.current;
// if (!map) return;
// const handleLayerVisibilityChange = () => {
// updatePopupVisibility();
// };
// map.getLayers().on('change:length', handleLayerVisibilityChange);
// map.getLayers().forEach(layer => {
// layer.on('change:visible', handleLayerVisibilityChange);
// });
// return () => {
// // 클린업
// map.getLayers().un('change:length', handleLayerVisibilityChange);
// map.getLayers().forEach(layer => {
// layer.un('change:visible', handleLayerVisibilityChange);
// });
// };
// }, [updatePopupVisibility]);
// 체크된 레이어 정보를 처리하는 함수
const handleCheckedLayersChange = useCallback((checkedIds, checkedNodesData) => {
setTimeout(() => {
setCheckedLayers(checkedIds);
setCheckedLayersData(checkedNodesData);
onCheckedLayersChange?.(checkedIds, checkedNodesData);
}, 0);
}, [onCheckedLayersChange]);
return (
<>
<GisContext.Provider value={{ gisCore, gisMap: mapInstanceRef.current, treeData, checkedLayerIds }}>
<div className="h-[calc(100vh-10rem)] flex relative">
<div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} />
<GisTree
visible={tabVisible}
setVisible={setTabVisible}
onCheckedChange={handleCheckedLayersChange}
onLayerIconClick={(nodeNm) => {
layerNmRef.current = nodeNm
setLayerNm(nodeNm); setIsOpen(true); const dummyFeature = {
getProperties: () => ({}), // 최소 구조
};
onFeatureSelect(dummyFeature);
}}
imageVisible={imgVisble}
/>
{/* <GisContent onFeatureSelect={handleFeatureSelect} isSearch={isSearch} isSearchModalOpen={isSearchModalOpen} setIsSearchModalOpen={setIsSearchModalOpen} /> */}
{/* DetailView가 존재할 때만 렌더링 isSearchModalOpen이 true면 visible=false로 팝업 숨김 */}
{isOpen && !isSearchModalOpen && (
<DetailView
featureData={featureData || { getProperties: () => ({}) }}
seletectedLayerNm={layerNm}
onClose={closePopup} // 닫기 콜백 전달
onFeatureSelect={handleFeatureSelect}
/>
)}
{/* SearchView가 존재할 때만 렌더링 */}
{SearchView && (
<SearchView
open={isSearchModalOpen}
onOpenChange={setIsSearchModalOpen}
onFeatureSelect={handleFeatureSelect}
/>
)}
<GisLonLat />
<GisToolBar />
<GisMapToggle />
<GisSelect onFeatureSelect={handleFeatureSelect} />
</div>
{isSearch && (
<div className="absolute right-8 top-[305px] z-20">
<button
onClick={() => setIsSearchModalOpen(true)}
className="overflow-hidden w-[40px] h-[40px] bg-white
border border-[#9aa7b8]
rounded
shadow-[0_0_5px_#999]
flex items-center justify-center
p-0
"
>
<Search size={24} className="text-gray-700" />
</button>
</div>
)}
</GisContext.Provider>
</>
);
{isSearch && (
<div className="absolute right-8 top-[305px] z-20">
<button
onClick={() => setIsSearchModalOpen(true)}
title="정보 검색"
className="flex h-[40px] w-[40px] items-center justify-center overflow-hidden rounded border border-[#9aa7b8] bg-white p-0 shadow-[0_0_5px_#999]"
>
<Search size={24} className="text-gray-700" />
</button>
</div>
)}
</GisContext.Provider>
</>
);
}
export default React.memo(Gis);
@@ -1,74 +1,61 @@
import { Search } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useGisContext } from './Gis';
import './lib/css/gis.edit.css';
import GisApp from './lib/gis.core';
import { Search } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useGisContext } from "./Gis";
import "./lib/css/gis.edit.css";
import GisApp from "./lib/gis.core";
export default function GisContent({ edit = false, onFeatureSelect, isSearch, setIsSearchModalOpen }) {
const { gisMap, gisCore } = useGisContext();
const [visibleComponents, setVisibleComponents] = useState({});
const addressResultLayer = useRef(null);
const [playShow, setPlayShow] = useState({ show: false });
export default function GisContent({
edit = false,
onFeatureSelect,
isSearch,
setIsSearchModalOpen,
}) {
const { gisMap, gisCore } = useGisContext();
//셀렉트 버튼 이벤트
// const clickFeature = useCallback(feature => {
// if (feature) {
// const layer = feature.getLayer();
// setVisibleComponents(prevState => ({
// ...prevState,
// }));
// }
// }, []);
//셀렉트 버튼 이벤트
const selectRslt = useCallback((feature) => {
GisApp.Module.layer.highLightFeature(
[feature],
{
id: GisApp.hlLayerId,
zIndex: 10,
opacity: 1,
selectable: true,
},
{ width: 30 },
);
clickFeature(feature);
}, []);
const selectRslt = useCallback(feature => {
GisApp.Module.layer.highLightFeature(
[feature],
{
id: GisApp.hlLayerId,
zIndex: 10,
opacity: 1,
selectable: true,
},
{ width: 30 }
);
clickFeature(feature);
}, []);
useEffect(() => {
if (gisMap) {
setVisibleComponents((prevState) => ({
...prevState,
}));
}
}, [gisMap]);
useEffect(() => {
if (gisMap) {
setVisibleComponents(prevState => ({
...prevState,
}));
}
}, [gisMap]);
if (!gisMap) return null;
if (!gisMap) return null;
return (
<>
<GisTree />
<GisMapToggle />
<GisLonLat />
<GisToolBar />
<GisSelect onFeatureSelect={onFeatureSelect} />
return (
<>
{/*<GisTree />*/}
{/* <GisMapToggle />
<GisLonLat />
<GisToolBar /> */}
{/* <GisSelect onFeatureSelect={onFeatureSelect} /> */}
{/* 검색 모달 */}
{isSearch && (
<div className="absolute right-8 top-[254px] z-20">
<button
onClick={() => setIsSearchModalOpen(true)}
className="overflow-hidden w-[40px] h-[40px] bg-white
border border-[#9aa7b8]
rounded
shadow-[0_0_5px_#999]
flex items-center justify-center
p-0
"
>
<Search size={24} className="text-gray-700" />
</button>
</div>
)}
</>
);
{/* 검색 모달 */}
{isSearch && (
<div className="absolute right-8 top-[254px] z-20">
<button
onClick={() => setIsSearchModalOpen(true)}
className="flex h-[40px] w-[40px] items-center justify-center overflow-hidden rounded border border-[#9aa7b8] bg-white p-0 shadow-[0_0_5px_#999]"
>
<Search size={24} className="text-gray-700" />
</button>
</div>
)}
</>
);
}
@@ -1,46 +1,42 @@
import React, { useContext, useEffect } from 'react';
import { GisContext } from './Gis';
import GisApp from './lib/gis.core';
import GisOlSelect from './lib/gis.select';
import React, { useContext, useEffect } from "react";
import { GisContext } from "./Gis";
import GisApp from "./lib/gis.core";
import GisOlSelect from "./lib/gis.select";
export default React.memo(function GisSelect({ onFeatureSelect }) {
const { gisMap } = useContext(GisContext);
const { gisMap } = useContext(GisContext);
useEffect(() => {
if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함
// 이미 select가 있으면 재생성하지 않음
const select = new GisOlSelect(gisMap);
GisApp.Module.select = select;
useEffect(() => {
if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함
// 피처 선택 시 콜백 등록
select.getSelectedInfo(function (features) {
if (features?.[0]) {
console.log("GisSelect features", features);
onFeatureSelect(features[0]);
}
});
}, [gisMap, onFeatureSelect]);
// 이미 select가 있으면 재생성하지 않음
if (!GisApp.Module.select) {
const select = new GisOlSelect(gisMap);
GisApp.Module.select = select;
// const select = new GisOlSelect(gisMap);
// GisApp.Module.select = select;
// 피처 선택 시 콜백 등록
select.getSelectedInfo(function (features) {
if (features?.[0]) {
console.log('GisSelect features', features);
onFeatureSelect(features[0]);
}
});
}
}, [gisMap, onFeatureSelect]);
// select.getSelectedInfo(function (features) {
// //clickFeature(features[0]);
// if(features.length > 0) {
// const layer = features[0];
// // console.log('GisSelect layer', layer);
// onFeatureSelect(layer); //피쳐 속성데이터 상위컴포넌트로 전달
// }
// });
// const select = new GisOlSelect(gisMap);
// GisApp.Module.select = select;
// select.getSelectedInfo(function (features) {
// //clickFeature(features[0]);
// if(features.length > 0) {
// const layer = features[0];
// // console.log('GisSelect layer', layer);
// onFeatureSelect(layer); //피쳐 속성데이터 상위컴포넌트로 전달
// }
// });
return (
<>
{/* <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}>
return (
<>
{/* <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}>
<h3>선,면,다각형을 이용한 시설물 선택 영역</h3>
<ul>
<li>
@@ -55,6 +51,6 @@ export default React.memo(function GisSelect({ onFeatureSelect }) {
</li>
</ul>
</div> */}
</>
);
</>
);
});
@@ -1,84 +0,0 @@
// import React, { useState } from 'react';
// import { Tabs, TabsList, TabsTrigger } from '@common/components/ui/tabs';
// import GisTree from './GisTree';
// import GisSearch from './GisSearch';
// import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
// import SearchModal from '@/map/SearchModal';
// function GisTab({ visible, setVisible, isSearch, isSearchModalOpen, setIsSearchModalOpen, onFeatureSelect }) {
// const [activeTab, setActiveTab] = useState('layers');
// return (
// <>
// <div
// className={`absolute top-0 left-0 w-[250px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-40
// ${visible ? 'translate-x-0 opacity-100' : '-translate-x-full opacity-0 pointer-events-none'}`}
// >
// <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
// <TabsList className="flex-none mt-4 border-none rounded-none">
// <TabsTrigger value="layers" className="flex-1 h-10">레이어</TabsTrigger>
// {/* {isSearch && (
// <button
// onClick={() => setIsSearchModalOpen(true)}
// className="h-10 w-10 flex items-center justify-center px-0"
// >
// <Search size={18} className="text-gray-700" />
// </button>
// // <TabsTrigger value="search" className="flex-1 h-10 flex items-center justify-center">
// // <Search size={18} />
// // </TabsTrigger>
// )} */}
// </TabsList>
// {/* GisTree는 항상 마운트되며, 탭 상태에 따라 보여주기만 함 */}
// <div
// className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${activeTab === 'layers' ? 'block' : 'hidden'
// }`}
// >
// <GisTree />
// </div>
// {/* GisSearch도 동일하게 처리 */}
// {/* {isSearch && (
// <div
// className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${
// activeTab === 'search' ? 'block' : 'hidden'
// }`}
// >
// <GisSearch />
// </div>
// )} */}
// {/* 🔍 검색 모달 */}
// {/* <SearchModal
// open={isSearchModalOpen}
// onOpenChange={setIsSearchModalOpen}
// onFeatureSelect={onFeatureSelect}
// /> */}
// </Tabs>
// {/* 접기 버튼 */}
// <button
// onClick={() => setVisible(false)}
// className="absolute top-1/2 right-[-3px] transform -translate-y-1/2 z-50 bg-white border border-l border-gray-300 rounded-l-none rounded-r px-1 py-2 shadow-sm"
// >
// <ChevronLeft size={16} />
// </button>
// </div>
// {/* 열기 버튼 */}
// {!visible && (
// <button
// onClick={() => setVisible(true)}
// className="absolute top-1/2 left-[-3px] transform -translate-y-1/2 z-50 bg-white border border-r border-gray-300 rounded-r px-1 py-2 shadow-sm"
// >
// <ChevronRight size={16} />
// </button>
// )}
// </>
// );
// }
// export default React.memo(GisTab);
@@ -1,65 +1,71 @@
import {
MultiBackend,
Tree,
getBackendOptions
getBackendOptions,
DndProvider,
} from "@minoru/react-dnd-treeview";
import { ChevronLeft, ChevronRight } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { DndProvider } from "react-dnd";
import { ChevronLeft, ChevronRight } from "lucide-react";
import React, { useEffect, useState, useRef, useCallback } from "react";
import { useGisContext } from './Gis';
import GisApp from './lib/gis.core';
import { useGisContext } from "./Gis";
import GisApp from "./lib/gis.core";
export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes = () => { }, visible, setVisible, onCheckedChange, onLayerIconClick,imageVisible=false }) {
/**
* GisTree (React memoized component)
*
* 계층형 GIS 레이어 트리를 렌더링하고, 체크 상태에 따라 지도상의 레이어 On/Off를 제어합니다.
* 또한 특정 순서/간격으로 레이어를 자동으로 토글하는 애니메이션 기능을 제공합니다.
*
* Props 요약
* - checkedNodes, setCheckedNodes: (선택) 외부 관리 체크 상태 (현재 내부 state 사용)
* - visible, setVisible: 사이드 패널 가시성 on/off
* - onCheckedChange(checkedIds, checkedNodesData): 체크 변경 시 콜백
* - onLayerIconClick(nodeNm): 각 노드의 미리보기/아이콘 클릭 시 콜백
* - imageVisible: 레이어 옆 아이콘(썸네일) 노출 여부
* - isPlaying, setIsPlaying: 자동 애니메이션 재생 상태 제어
* - targetLayerTypes: 자동 애니메이션 대상이 되는 레이어 type 배열 (예: ['tb_ms_swage_mhl'])
* - orderedLayers: 레이어 재생 순서를 지정하는 id 배열 (lyrSn)
* - checkInterval: 자동 애니메이션 토글 간격(ms)
* - onProgressChange(total, current): 애니메이션 진행상태 콜백
*/
export default React.memo(function GisTree({
checkedNodes = [],
setCheckedNodes = () => { },
visible,
setVisible,
onCheckedChange,
onLayerIconClick,
imageVisible = false,
isPlaying = false,
setIsPlaying = () => { },
targetLayerTypes = [],
checkInterval = 3000,
onProgressChange = () => { },
}) {
const { gisCore, treeData } = useGisContext();
// 체크/확장/노드/탭 상태
const [checked, setChecked] = useState([]);
const [expanded, setExpanded] = useState([]);
const [nodes, setNodes] = useState([]);
const [activeTab, setActiveTab] = useState('layers');
const [activeTab, setActiveTab] = useState("layers");
useEffect(() => {
if (treeData && treeData.length > 0) {
const nodeData = setNodeValues(treeData);
setNodes(nodeData);
gisCore.setAllLayersOnMap(false);
}
}, [treeData]);
// 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머)
const currentAnimationIndexRef = useRef(0);
const targetNodesRef = useRef([]);
const isPausedRef = useRef(false);
const timeoutIdRef = useRef(null);
// 트리 노드 체크 시 레이어 표시/숨김
useEffect(() => {
if (nodes.length > 0) {
// 기본으로 체크된 노드들 찾기
const defaultChecked = getDefaultCheckedNodes(nodes);
setChecked(defaultChecked);
console.log('defaultChecked : ', defaultChecked);
// 기본으로 확장된 노드들 설정
const defaultExpanded = Array.from(new Set([
...defaultChecked,
...defaultChecked.flatMap((id) => getAncestors(id, nodes))
]));
setExpanded(defaultExpanded);
// 기본 체크된 레이어들을 지도에 표시
gisCore.setLayerOnMap(defaultChecked, true);
// 초기 체크된 레이어 정보를 상위로 전달
const defaultCheckedNodesData = nodes.filter(node => defaultChecked.includes(node.id));
onCheckedChange?.(defaultChecked, defaultCheckedNodesData);
}
}, [nodes]); // onCheckedChange를 의존성에 추가
// 초기 표시 여부가 'Y'인 레이어들 찾기
const getDefaultCheckedNodes = (nodes, values = []) => {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.data.initEprssYn === 'Y') {
values.push(node.data.lyrSn);
}
}
return values;
};
// =====================
// 유틸리티: 트리 탐색 관련 함수들
// =====================
/**
* 주어진 id의 모든 하위(자손) 노드 id를 평탄 배열로 반환합니다.
* @param {number|string} id 기준 노드 id
* @param {Array} all 전체 노드 배열
* @returns {Array} 자손 노드 id 배열
*/
const getDescendants = (id, all) => {
const children = all.filter((n) => n.parent === id);
return children.reduce(
@@ -68,97 +74,394 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes
);
};
/**
* 주어진 id의 모든 상위(조상) 노드 id를 상단부터 배열로 반환합니다.
* @param {number|string} id 기준 노드 id
* @param {Array} all 전체 노드 배열
* @returns {Array} 조상 노드 id 배열
*/
const getAncestors = (id, all) => {
const node = all.find((n) => n.id === id);
if (!node || node.parent == null) return [];
return [node.parent, ...getAncestors(node.parent, all)];
};
const isEveryChildChecked = (parentId, all, checked) => {
const children = all.filter((n) => n.parent === parentId);
return children.every((c) => checked.includes(c.id));
/**
* 특정 parentId의 직계 자식 노드 리스트를 반환합니다.
*/
const getChildren = (parentId, all) => all.filter((n) => n.parent === parentId);
/**
* parentId의 모든 직계 자식이 checkedIds에 포함되어 있는지 확인합니다.
*/
const areAllChildrenChecked = (parentId, all, checkedIds) => {
const children = getChildren(parentId, all);
return children.length > 0 && children.every((c) => checkedIds.includes(c.id));
};
/**
* 어떤 노드의 체크 변경 이후, 상위 부모까지 올라가며 체크 상태를 전파/정리합니다.
* - 모든 자식이 체크되면 부모도 체크
* - 일부라도 해제되면 부모 해제
*/
const updateParentCheckState = (nodeId, all, checkedIds) => {
const parent = all.find((n) => n.id === nodeId)?.parent;
if (parent == null) return checkedIds;
if (areAllChildrenChecked(parent, all, checkedIds)) {
if (!checkedIds.includes(parent)) checkedIds.push(parent);
} else {
checkedIds = checkedIds.filter((id) => id !== parent);
}
return updateParentCheckState(parent, all, checkedIds);
};
/**
* parentId의 모든 직계 자식이 checked 배열에 포함되어 있는지 확인합니다.
*/
const isEveryChildChecked = (parentId, all, checked) => {
const children = getChildren(parentId, all);
return children.length > 0 && children.every((c) => checked.includes(c.id));
};
// =====================
// 기본 체크 노드 계산
// =====================
/**
* 초기 렌더 시 기본으로 체크할 노드 id 목록을 반환합니다.
* - data.initEprssYn === 'Y' 인 노드 + 부모 전파
*/
const getDefaultCheckedNodes = (nodes) => {
let checkedIds = nodes
.filter((n) => n.data.initEprssYn === "Y")
.map((n) => n.id);
nodes.forEach((node) => {
checkedIds = updateParentCheckState(node.id, nodes, checkedIds);
});
return Array.from(new Set(checkedIds));
};
// =====================
// 초기 로딩: 트리 데이터 세팅 & 초기 체크/확장 & 지도 반영
// =====================
/**
* treeData가 들어오면 노드 변환 후, 모든 레이어를 지도에서 잠시 비활성화합니다.
*/
useEffect(() => {
if (treeData && treeData.length > 0) {
const nodeData = setNodeValues(treeData);
setNodes(nodeData);
gisCore.setAllLayersOnMap(false);
}
}, [treeData]);
/**
* nodes가 준비되면 기본 체크/확장 상태를 계산하고, 실제 지도 레이어 on/off 반영 및 콜백 호출.
*/
useEffect(() => {
if (nodes.length > 0) {
const defaultChecked = getDefaultCheckedNodes(nodes);
setChecked(defaultChecked);
// 기본 확장 = 체크된 노드 + 그 조상들
const defaultExpanded = Array.from(
new Set([
...defaultChecked,
...defaultChecked.flatMap((id) => getAncestors(id, nodes)),
])
);
setExpanded(defaultExpanded);
// 지도 반영
gisCore.setLayerOnMap(defaultChecked, true);
const defaultCheckedNodesData = nodes.filter((node) =>
defaultChecked.includes(node.id)
);
onCheckedChange?.(defaultChecked, defaultCheckedNodesData);
}
}, [nodes]);
// =====================
// 체크 이벤트: 트리에서 체크 박스 클릭 시 처리
// =====================
/**
* 단일 노드 체크/해제 처리 핸들러
* - 체크 시: 자신 + 모든 자손 체크
* - 해제 시: 자신 + 모든 자손 해제
* - 이후 부모 상태 전파 및 지도 레이어 on/off 반영, 콜백 호출
*/
const handleCheck = (id) => {
setChecked((prevChecked) => {
const isChecked = prevChecked.includes(id);
let newChecked = [...prevChecked];
const isChecked = prevChecked.includes(id);
if (isChecked) {
// 해제: 자신 + 자손 제거
const descendants = getDescendants(id, nodes);
newChecked = newChecked.filter((cid) => cid !== id && !descendants.includes(cid));
const ancestors = getAncestors(id, nodes);
ancestors.forEach((p) => {
if (newChecked.includes(p)) {
newChecked = newChecked.filter((cid) => cid !== p);
}
});
newChecked = newChecked.filter(
(cid) => cid !== id && !descendants.includes(cid)
);
} else {
// 체크: 자신 + 자손 추가
const descendants = getDescendants(id, nodes);
newChecked = Array.from(new Set([...newChecked, id, ...descendants]));
const ancestors = getAncestors(id, nodes);
ancestors.forEach((p) => {
if (isEveryChildChecked(p, nodes, newChecked)) {
newChecked.push(p);
}
});
// setExpanded((prev) => Array.from(new Set([...prev, ...ancestors])));
}
// 부모 전파 (모든 자식 체크 시 부모 체크)
newChecked = updateParentCheckState(id, nodes, newChecked);
// 지도 반영
const allIds = nodes.map((n) => n.id);
const unchecked = allIds.filter((nid) => !newChecked.includes(nid));
gisCore.setLayerOnMap(newChecked, true);
gisCore.setLayerOnMap(unchecked, false);
// 체크된 노드 정보와 함께 노드 데이터를 상위로 전달
const checkedNodesData = nodes.filter(node => newChecked.includes(node.id));
// 콜백
const checkedNodesData = nodes.filter((node) => newChecked.includes(node.id));
onCheckedChange?.(newChecked, checkedNodesData);
return newChecked;
});
};
// =====================
// 외부 호출용: 특정 노드 강제 체크/해제
// =====================
/**
* 특정 nodeId를 강제로 체크/해제합니다.
* @param {number|string} nodeId 대상 노드 id
* @param {boolean} shouldCheck true면 체크, false면 해제
*
* 내부적으로 handleCheck와 동일한 부모 전파/지도 반영/콜백 과정을 수행합니다.
*/
const checkSpecificNode = useCallback(
(nodeId, shouldCheck = true) => {
setChecked((prevChecked) => {
let newChecked = [...prevChecked];
const isCurrentlyChecked = prevChecked.includes(nodeId);
if (shouldCheck && !isCurrentlyChecked) {
// 체크: 자신 + 자손 추가, 부모 전파
const descendants = getDescendants(nodeId, nodes);
newChecked = Array.from(new Set([...newChecked, nodeId, ...descendants]));
const ancestors = getAncestors(nodeId, nodes);
ancestors.forEach((p) => {
if (isEveryChildChecked(p, nodes, newChecked)) newChecked.push(p);
});
} else if (!shouldCheck && isCurrentlyChecked) {
// 해제: 자신 + 자손 제거, 부모 해제
const descendants = getDescendants(nodeId, nodes);
newChecked = newChecked.filter(
(cid) => cid !== nodeId && !descendants.includes(cid)
);
const ancestors = getAncestors(nodeId, nodes);
ancestors.forEach((p) => {
if (newChecked.includes(p)) newChecked = newChecked.filter((cid) => cid !== p);
});
}
// 지도 반영
const allIds = nodes.map((n) => n.id);
const unchecked = allIds.filter((nid) => !newChecked.includes(nid));
gisCore.setLayerOnMap(newChecked, true);
gisCore.setLayerOnMap(unchecked, false);
// 콜백
const checkedNodesData = nodes.filter((node) => newChecked.includes(node.id));
onCheckedChange?.(newChecked, checkedNodesData);
return newChecked;
});
},
[nodes]
);
// =====================
// 자동 애니메이션: 지정된 순서/간격으로 노드 체크 상태를 순차 변경
// =====================
/**
* 레이어 자동 토글 애니메이션을 시작/중지/초기화합니다.
* @param {boolean} isEnabled 재생 여부
* @param {string[]} layerTypes 대상 레이어 타입 목록 (node.data.lyrTyNm 기준)
* @param {number} interval 토글 간격(ms)
*/
const handleAutoCheckAnimation = useCallback(
(isEnabled, layerTypes = [], interval = 3000) => {
// 기존 타이머 정리
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
// 중지/초기화 경로
if (!isEnabled || layerTypes.length === 0) {
isPausedRef.current = false;
targetNodesRef.current = [];
currentAnimationIndexRef.current = 0;
onProgressChange(0, 0);
return;
}
// orderedLayers 순서를 우선 적용하면서, 타입 필터에 맞는 타겟 노드만 선별
const getTargetNodesInOrder = () => {
const target = nodes.filter((n) => n.data?.actlLyrYn === 'N' && layerTypes.includes(n.data?.lyrTyNm));
// drawOrdr 값을 기준으로 오름차순 정렬
target.sort((a, b) => {
const aOrder = a.data?.drawOrdr || 0;
const bOrder = b.data?.drawOrdr || 0;
return aOrder - bOrder;
});
return target;
};
const targetNodes = getTargetNodesInOrder();
if (targetNodes.length === 0) return;
// 최초 진입 시 전체 해제 후 시작
if (targetNodesRef.current.length === 0) {
targetNodesRef.current = targetNodes;
currentAnimationIndexRef.current = 0;
onProgressChange(targetNodes.length, 0);
targetNodes.forEach((node) => {
checkSpecificNode(node.id, false);
});
}
isPausedRef.current = false;
// 다음 레이어로 진행하는 내부 함수
const processNextLayer = () => {
if (isPausedRef.current || !isPlaying) return; // 일시정지/정지 처리
const currentTargetNodes = targetNodesRef.current;
const currentIndex = currentAnimationIndexRef.current;
if (currentIndex < currentTargetNodes.length) {
// 현재 타겟 on, 직전 타겟 off
checkSpecificNode(currentTargetNodes[currentIndex].id, true);
if (currentIndex > 0) {
checkSpecificNode(currentTargetNodes[currentIndex - 1].id, false);
}
currentAnimationIndexRef.current++;
onProgressChange(
currentTargetNodes.length,
currentAnimationIndexRef.current
);
timeoutIdRef.current = setTimeout(processNextLayer, interval);
} else {
// 종료 처리 및 상태 초기화
isPausedRef.current = false;
targetNodesRef.current = [];
currentAnimationIndexRef.current = 0;
timeoutIdRef.current = null;
onProgressChange(currentTargetNodes.length, currentTargetNodes.length);
setTimeout(() => onProgressChange(0, 0), 1000);
setIsPlaying?.(false);
}
};
processNextLayer();
},
[nodes, checkSpecificNode, setIsPlaying, isPlaying, onProgressChange]
);
/**
* 자동 애니메이션 일시정지
*/
const pauseAnimation = useCallback(() => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
isPausedRef.current = true;
}, []);
/**
* 재생/정지/대상/간격 변경에 반응하여 애니메이션을 관리합니다.
*/
useEffect(() => {
if (isPlaying)
handleAutoCheckAnimation(true, targetLayerTypes, checkInterval);
else pauseAnimation();
}, [
isPlaying,
handleAutoCheckAnimation,
targetLayerTypes,
checkInterval,
pauseAnimation,
]);
/**
* 언마운트 시 타이머 정리.
*/
useEffect(() => {
return () => {
if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current);
};
}, []);
// =====================
// 노드/아이콘 렌더링 데이터 준비
// =====================
/**
* 서버에서 내려온 트리 데이터를 Tree 컴포넌트가 요구하는 구조로 변환합니다.
* - id, text, parent, droppable, data, icon
*/
const setNodeValues = (result) => {
return result.map((item) => {
const src = makeLegendSrc(item);
let label = item.lyrNm;
return {
id: item.lyrSn,
text: label,
parent: item.upperLyrSn,
droppable: !!item.children,
id: item.lyrSn, // 반드시 고유값
text: item.lyrNm,
parent: item.upperLyrSn || null, // 루트는 null
droppable: true, // children 여부 모를 땐 true로 둬야 트리 확장 가능
data: item,
icon: src ? <img src={src} alt={item.lyrNm} /> : '',
icon: src ? <img src={src} alt={item.lyrNm} /> : "",
};
});
};
const makeLegendSrc = layerinfo => {
/**
* 레이어 정보로부터 범례 이미지 경로 또는 동적 생성 이미지를 반환합니다.
*/
const makeLegendSrc = (layerinfo) => {
if (!layerinfo.gobjTy) return null;
let src;
if (layerinfo.iconNm) {
src = GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm;
return GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm;
} else {
const ss = makeLegendImage(layerinfo);
src = ss.src;
return makeLegendImage(layerinfo).src;
}
return src;
};
const makeLegendImage = layerinfo => {
let image = document.createElement('img');
let canvas = document.createElement('canvas');
/**
* 레이어 스타일 정보를 기반으로 작은 캔버스에 간단한 범례 이미지를 그려 dataURL을 생성합니다.
*/
const makeLegendImage = (layerinfo) => {
let image = document.createElement("img");
let canvas = document.createElement("canvas");
canvas.width = 16;
canvas.height = 16;
let ctx = canvas.getContext('2d');
let ctx = canvas.getContext("2d");
ctx.beginPath();
if (layerinfo.gobjTy === 'lineLabelPoligon') {
if (layerinfo.gobjTy === "lineLabelPoligon") {
ctx.moveTo(1, 1);
ctx.lineTo(15, 1);
ctx.lineTo(15, 15);
ctx.lineTo(1, 15);
ctx.lineTo(1, 1);
} else if (layerinfo.gobjTy === 'line') {
} else if (layerinfo.gobjTy === "line") {
ctx.moveTo(1, 15);
ctx.lineTo(5, 1);
ctx.lineTo(9, 15);
@@ -171,83 +474,131 @@ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes
ctx.lineTo(1, 1);
}
if (layerinfo.gobjTy === 'lineLabelPoligon') {
if (layerinfo.gobjTy === "lineLabelPoligon") {
ctx.fillStyle = layerinfo.polgnColor;
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = layerinfo.lnColor;
ctx.stroke();
}
if (layerinfo.gobjTy === 'line') {
if (layerinfo.gobjTy === "line") {
ctx.lineWidth = 1;
ctx.strokeStyle = layerinfo.lnColor;
ctx.stroke();
}
image.src = canvas.toDataURL('image/png');
image.src = canvas.toDataURL("image/png");
return image;
};
/**
* 각 레이어 행의 아이콘 클릭 시 호출. 선택되지 않은 레이어면 경고, 선택된 경우 콜백 전달.
*/
const handleLayerImgClick = (nodeNm, nodeId) => {
if(!checked.includes(nodeId)) alert('시설을 선택해주세요')
else onLayerIconClick(nodeNm)
}
if (!checked.includes(nodeId)) alert("시설을 선택해주세요");
else onLayerIconClick(nodeNm);
};
// =====================
// 렌더링
// =====================
if (!treeData || treeData.length === 0) return null;
return (
<>
{/* 사이드 패널 */}
<div
className={`absolute top-0 left-0 w-[250px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-40
${visible ? 'translate-x-0 opacity-100' : '-translate-x-full opacity-0 pointer-events-none'}`}
className={`absolute left-0 top-0 z-40 flex h-full w-[250px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300 ${visible
? "translate-x-0 opacity-100"
: "pointer-events-none -translate-x-full opacity-0"
}`}
>
<div
className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${activeTab === 'layers' ? 'block' : 'hidden'}`}
className={`grow overflow-y-auto border-none p-2 pt-0 shadow-none ${activeTab === "layers" ? "block" : "hidden"
}`}
>
<h3 className="text-3xl text-gray-600 text-bold font-bold mx-2 my-4 ">레이어</h3>
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600 text-center">시설물</h3>
<hr className="mb-4" />
{/* DnD Tree */}
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={nodes}
rootId={null}
initialOpen={expanded}
onOpenChange={setExpanded}
render={(node, { depth, isOpen, onToggle }) => (
<div
className="flex items-center gap-2 py-1 px-2 hover:bg-gray-100 rounded"
style={{ marginInlineStart: depth * 16 + (!node.droppable ? 16 : 0) }}
>
{node.droppable && (
<i onClick={onToggle} className={`diveicon di-arrow-forward mr-2 text-base text-label ${isOpen ? 'rotate-90' : ''}`}
title={isOpen ? '닫기' : '열기'}>
</i>
)}
<input type="checkbox" checked={checked.includes(node.id)} onChange={() => handleCheck(node.id)} className="cursor-pointer" />
{node.icon && <span>{node.icon}</span>}
<span className="text-[1.3rem] font-medium text-label">{node.text}</span>
{imageVisible ? depth > 0 && <span><img className="cursor-pointer w-5 h-5" src='/images/map/layericon/Layer_Select_BTN.png' onClick={()=>{handleLayerImgClick(node.text, node.id)}} alt={node.text}></img></span> : null}
</div>
)}
render={(node, { depth, isOpen, onToggle }) => {
const isVisible = node.data.exprsYn === "Y";
const hasVisibleChildren = nodes.some(
(child) =>
child.parent === node.id && child.data.exprsYn === "Y"
);
return (
<div
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
style={{
marginInlineStart: depth * 16,
display: isVisible ? "flex" : "none",
}}
>
{hasVisibleChildren ? (
<i
onClick={onToggle}
className={`diveicon di-arrow-forward mr-2 text-base text-label ${isOpen ? "rotate-90" : ""
}`}
title={isOpen ? "닫기" : "열기"}
/>
) : (
<span style={{ display: "inline-block", width: 16 }} />
)}
<input
type="checkbox"
checked={checked.includes(node.id)}
onChange={() => handleCheck(node.id)}
className="cursor-pointer"
/>
{node.icon && <span>{node.icon}</span>}
<span className="text-[1.3rem] font-medium text-label">
{node.text}
</span>
{imageVisible && depth > 0 && (
<span>
<img
className="h-5 w-5 cursor-pointer"
src="/images/map/layericon/Layer_Select_BTN.png"
onClick={() =>
handleLayerImgClick(
node.data.lyrTyNm,
node.id
)
}
alt={node.text}
/>
</span>
)}
</div>
);
}}
sort={(a, b) => {
const aOrder = parseInt(a.data?.sortOrdr ?? '0');
const bOrder = parseInt(b.data?.sortOrdr ?? '0');
const aOrder = parseInt(a.data?.sortOrdr ?? "0");
const bOrder = parseInt(b.data?.sortOrdr ?? "0");
return aOrder - bOrder;
}}
/>
</DndProvider>
</div>
{/* 접기 버튼 */}
<button
onClick={() => setVisible(false)}
className="absolute top-1/2 right-[-3px] transform -translate-y-1/2 z-50 bg-white border border-l border-gray-300 rounded-l-none rounded-r px-1 py-2 shadow-sm"
className="absolute right-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white px-1 py-2 shadow-sm"
>
<ChevronLeft size={16} />
</button>
</div>
{/* 열기 버튼 */}
{!visible && (
<button
onClick={() => setVisible(true)}
className="absolute top-1/2 left-[-3px] transform -translate-y-1/2 z-50 bg-white border border-r border-gray-300 rounded-r px-1 py-2 shadow-sm"
className="absolute left-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm"
>
<ChevronRight size={16} />
</button>
@@ -16,11 +16,8 @@ import { Point } from 'ol/geom';
import { getTopLeft } from 'ol/extent';
import TileWMTS from 'ol/source/WMTS';
import WMTSTileGrid from 'ol/tilegrid/WMTS';
// import { get as getProjection } from 'ol/proj';
import proj4 from 'proj4';
import { register } from 'ol/proj/proj4';
import olSourceImageWMS from 'ol/source/ImageWMS';
import { GeoJSON } from 'ol/format';
import GisOlEditLayer from './gis.edit';
import GisOlLayer from './gis.layer';
@@ -30,9 +27,10 @@ const GisApp = {};
*/
//레이어 정보
GisApp.layerInfos = [];
// 가뭄 레이어 날짜 전역 보관소 추가
GisApp.DROUGHT_DATES = { startYmd: '', endYmd: '' };
//기본정보
GisApp.GEOSERVER_URL = '/geoserver/hasudo/ows';
GisApp.SN_GEOSERVER_URL = '/sewerage-service/api/v1/map/geometry';
GisApp.GEOSERVER_WMS_URL = '/geoserver/hasudo/wms';
//맵 정보
@@ -42,9 +40,8 @@ GisApp.GOOGLE_SATELLITE_URL = `https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z
GisApp.GOOGLE_HYBRID_URL = `https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`;
GisApp.GOOGLE_3D_URL = `https://tile.googleapis.com/v1/3dtiles/root.json?key=${GisApp.GOOGLE_API_KEY}`;
// TODO: NGII 정보
GisApp.NGII_API_KEY = '04trYP9_xwLAfALjwZ-B8g'; // NGII에서 발급받은 API 키
GisApp.NGII_WMTS_URL = `https://map.ngii.go.kr/openapi/Gettile.do?apikey=${GisApp.NGII_API_KEY}`;
// NGII 정보(배경지도)
GisApp.NGII_WMTS_URL = process.env.NEXT_PUBLIC_NGII_URL;
GisApp.EMAP_URL = '/emap/';
GisApp.EMAP_SATELLITE_URL = '/emap/satellite/';
@@ -561,18 +558,24 @@ GisApp.core.prototype = {
*/
setLayerOnMap: function (layerIds, visible, addQurryFlter) {
for (let i in layerIds) {
let id = layerIds[i];
let layer = this.getLayerById(id);
if (layer) {
layer.setVisible(visible);
} else if (visible) {
const layerCode = GisApp.layerCode[id];
const actlLyrYn = layerCode.actlLyrYn;
if(actlLyrYn == 'N') continue;
if(!layerCode.lyrTyNm) continue;
const queryFlter = layerCode?.flterSql || '' + ' ' + (addQurryFlter || '');
layerCode['flterSql'] = queryFlter;
//layer = this.addWmsLayer(layerCode);
layer = this.addWfsLayer(layerCode);
if(layerCode.srvcNm == 'wms'){
layer = this.addWmsLayer(layerCode);
}else {
layer = this.addWfsLayer(layerCode);
}
}
}
if (GisApp.Module.select) {
@@ -71,7 +71,7 @@ GisOlLayer.prototype = {
/**
* [사용]WFS 벡터 소스 반환
*/
getVectorSourceGeoserver: function (options) {
getVectorSourceBbox: function (options) {
let url = GisApp.GEOSERVER_URL;
let toProj = GisApp.TO_PROJ;
let lyrTyNm = options.lyrTyNm;
@@ -110,29 +110,31 @@ GisOlLayer.prototype = {
getVectorTileSource: function (options) {
let url = GisApp.GEOSERVER_URL;
let toProj = GisApp.TO_PROJ;
let lyrTyNm = options.lyrTyNm;
let typename = this.storageNm + lyrTyNm;
let filter = options?.flterSql && options.flterSql && options.flterSql.trim() !== '';
let params = {};
params['TYPENAME'] = this.storageNm + options.typeName;
params['TYPENAME'] = typename;
params['SERVICE'] = 'WFS';
params['VERSION'] = '1.1.0';
params['REQUEST'] = 'GetFeature';
params['SRS'] = toProj;
params['OUTPUTFORMAT'] = 'application/json';
if (filter) params['CQL_FILTER'] = filter;
else if (GisApp.DROUGHT_DATES) {
params['START_YMD'] = GisApp.DROUGHT_DATES.startYmd;
params['END_YMD'] = GisApp.DROUGHT_DATES.endYmd;
}
let strParams = new URLSearchParams(params).toString();
let source = new VectorTile({
let source = new VectorSource({
format: new GeoJSON(),
tileUrlFunction: function (tileCoord, pixelRatio, projection) {
let filterStr = '&CQL_FILTER=bbox(GEOM,' + source.getTileGrid().getTileCoordExtent(tileCoord).join(',') + ",'" + projection.getCode() + "')";
if (options.flterSql) {
filterStr += ' AND ' + options.flterSql;
}
let paramWithUrl = url + '?' + strParams + filterStr;
return paramWithUrl;
url: function (extent) {
return url + '?' + strParams;
},
tileGrid: createXYZ,
strategy: all,
});
return source;
},
/**
@@ -142,8 +144,21 @@ GisOlLayer.prototype = {
option = option || {};
//레이어 생성
let returnLayer = this.makeLayer(option);
//레이어 소스 생성
let source = this.getVectorSourceGeoserver(option);
let source;
let cmncNm = option.cmncNm;
try {
if(cmncNm == 'bbox') {
source = this.getVectorSourceBbox(option);
}else {
source = this.getVectorTileSource(option);
}
}catch(error){
console.warn(option);
}
returnLayer.setSource(source);
//스타일 추가
returnLayer.setStyle(this.gisStyle.getLayerStyleFunction(returnLayer, option));
@@ -554,6 +569,250 @@ GisOlLayer.prototype = {
cesiumScene.camera.moveEnd.addEventListener(cesiumScene._moveEndListener);
}
},
getFeatureById: function (featureId) {
let returnFeature;
this.layers.getArray().some(function (layer) {
const source = layer.getSource();
if (source instanceof VectorSource) {
const feature = source.getFeatureById(featureId);
if (feature) {
returnFeature = feature;
return true;
}
}
});
return returnFeature;
},
getOpenLayersZoom: function (cesiumScene) {
const cameraHeight = cesiumScene.camera.positionCartographic.height;
const maxResolution = 156543.03392804097; // OpenLayers 기본 해상도 (Google Mercator 기준)
const tileSize = 256; // 기본 타일 크기
return Math.ceil(Math.log2(maxResolution / (cameraHeight / tileSize)));
},
ensureSearchLayer: function (option) {
let layer = this.searchLayer;
if (!layer) {
let source;
try {
if (option.cmncNm == 'bbox') {
source = this.getVectorSourceBbox(option);
} else {
source = this.getVectorSourceAll(option);
}
} catch (error) {
console.warn(option);
}
layer = new VectorImageLayer({
...option,
id: 'search-layer',
source: source,
zIndex: 9999
});
layer.setStyle(this.gisStyle.getLayerStyleFunction(layer, option));
let map = GisApp.Module.core.map;
map.addLayer(layer);
this.searchLayer = layer;
}
return layer;
},
clearSearchLayer: function (option) {
const layer = this.ensureSearchLayer(option);
layer.getSource().clear(true);
if (this.searchLayer) {
GisApp.Module.core.map.removeLayer(this.searchLayer);
this.searchLayer = null;
}
},
showSearchResultsFromGeoJSON: function (lyrTyNm, featureId) {
//레이어 타입 설정(point, line 등. 이걸 style에서 사용)
const layerCode = GisApp.layerCode;
const matched = Object.values(layerCode).find(obj => obj.lyrTyNm === lyrTyNm);
// 이전 검색 레이어 완전히 제거
this.clearSearchLayer(matched);
// 새로운 검색 레이어 생성
const layer = this.ensureSearchLayer(matched);
layer.set("gobjTy", matched.gobjTy);
// 새 소스 만들기
const source = this.getFeatureSourceFromGeoserver(lyrTyNm, featureId, matched);
// 1) 로드 완료시 extent 맞추기
const onLoadEnd = () => {
if (Object.keys(source.nullGeometryFeatures_).length > 0) alert('해당 시설의 위치 정보가 없습니다.');
const feats = source.getFeatures();
if (feats && feats.length > 0) {
const select = GisApp.Module.select;
select.selectFeature(feats[0], false);
} else {
console.warn('[search-layer] GeoJSON에 피처가 없습니다.');
}
// 한번만 쓰고 이벤트 정리
source.un('featuresloadend', onLoadEnd);
source.un('featuresloaderror', onLoadError);
};
const onLoadError = (e) => {
console.error('[search-layer] GeoJSON 로드 실패:', e);
source.un('featuresloadend', onLoadEnd);
source.un('featuresloaderror', onLoadError);
};
// 이벤트를 먼저 걸고 → setSource
source.once('featuresloadend', onLoadEnd);
source.once('featuresloaderror', onLoadError);
// 레이어에 소스 적용(이때부터 로딩 시작)
layer.setSource(source);
// 필요시 명시적으로 재요청
source.refresh();
},
/**
* 피쳐 ID로 지오서버에서 검색해서 피처의 벡터소스를 리턴
* @param {} lyrTyNm
* @param {*} featureId
* @returns
*/
getFeatureSourceFromGeoserver: function (lyrTyNm, featureId, option) {
const typeName = this.storageNm + lyrTyNm;
const srsName = GisApp.TO_PROJ;
const baseUrl = GisApp.GEOSERVER_URL;
const source = new VectorSource({
...option,
format: new GeoJSON(),
url: () => {
const params = new URLSearchParams({
SERVICE: 'WFS',
VERSION: '1.1.0',
REQUEST: 'GetFeature',
TYPENAME: typeName,
SRSNAME: srsName,
OUTPUTFORMAT: 'application/json',
FEATUREID: featureId,
});
return `${baseUrl}?${params.toString()}`;
},
strategy: all,
});
return source;
},
/**
* 여러 개의 검색 결과를 GeoJSON으로 표시
* @param {string} lyrTyNm 레이어 타입명
* @param {Array|string} featureIds 피처 ID 배열 또는 단일 ID
*/
showMultipleSearchResultsFromGeoJSON: function (lyrTyNm, featureIds) {
const layerCode = GisApp.layerCode;
const matched = Object.values(layerCode).find(obj => obj.lyrTyNm === lyrTyNm);
// 이전 검색 레이어 완전히 제거
this.clearSearchLayer(matched);
// 새로운 검색 레이어 생성
const layer = this.ensureSearchLayer(matched);
layer.set("gobjTy", matched.gobjTy);
const source = this.getMultiFeatureSourceFromGeoserver(lyrTyNm, featureIds);
const onLoadEnd = async () => {
const feats = source.getFeatures();
if (feats && feats.length > 0) {
const select = GisApp.Module.select;
await select.resetSelectedFeature();
for (const feature of feats) select.selectMultiFeature(feature, true);
const geometries = feats.map(f => f.getGeometry());
if (geometries.length > 0) {
const extent = this.combineExtents(geometries.map(g => g?.getExtent()));
let map = GisApp.Module.core.map;
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16
});
}
} else {
console.warn('[search-layer] GeoJSON에 피처가 없습니다.');
}
source.un('featuresloadend', onLoadEnd);
source.un('featuresloaderror', onLoadError);
};
const onLoadError = (e) => {
console.error('[search-layer] GeoJSON 로드 실패:', e);
source.un('featuresloadend', onLoadEnd);
source.un('featuresloaderror', onLoadError);
};
source.once('featuresloadend', onLoadEnd);
source.once('featuresloaderror', onLoadError);
layer.setSource(source);
source.refresh();
},
/**
* 피쳐 ID로 지오서버에서 검색해서 여러개 피처의 벡터소스를 리턴
* @param {} lyrTyNm
* @param {*} featureId
* @returns
*/
getMultiFeatureSourceFromGeoserver: function (lyrTyNm, featureId) {
const typeName = this.storageNm + lyrTyNm;
const srsName = GisApp.TO_PROJ;
const baseUrl = GisApp.GEOSERVER_URL;
const ids = Array.isArray(featureId) ? featureId : [featureId];
const source = new VectorSource({
format: new GeoJSON(),
url: () => {
const params = new URLSearchParams({
SERVICE: 'WFS',
VERSION: '1.1.0',
REQUEST: 'GetFeature',
TYPENAME: typeName,
SRSNAME: srsName,
OUTPUTFORMAT: 'application/json',
FEATUREID: ids.join(','),
});
return `${baseUrl}?${params.toString()}`;
},
strategy: all,
});
return source;
},
/**
* 여러 extent 배열을 받아 결합된 extent를 반환
* @param {Array<Array<number>>} extents [[minX,minY,maxX,maxY], ...]
* @returns {Array<number>|null}
*/
combineExtents: function (extents) {
if (!extents || extents.length === 0) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const e of extents) {
if (!e || e.length < 4) continue;
minX = Math.min(minX, e[0]);
minY = Math.min(minY, e[1]);
maxX = Math.max(maxX, e[2]);
maxY = Math.max(maxY, e[3]);
}
if (!isFinite(minX) || !isFinite(minY) || !isFinite(maxX) || !isFinite(maxY)) return null;
return [minX, minY, maxX, maxY];
}
};
function getOpenLayersZoom(cesiumScene) {
@@ -284,6 +284,7 @@ GisOlSelect.prototype = {
selectedFeatures.forEach(feature => {
selectedFeatures.remove(feature);
});
selectedFeatures.clear();
},
/**
* SELECT 이벤트 활성화
@@ -429,6 +430,19 @@ GisOlSelect.prototype = {
auto: auto,
});
},
/**
* 피처 다중 셀렉트
*/
selectMultiFeature: function (feature, auto) {
let me = this;
me.selectedFeatures.push(feature);
me.selectInteraction.dispatchEvent({
type: 'select',
selected: me.selectedFeatures,
deselected: [],
auto: auto,
});
},
};
export default GisOlSelect;
@@ -88,7 +88,7 @@ GisOlStyle.prototype = {
}
let baseResolution = 2;
let minScale = 0.4;
let minScale = 0.8;
let maxScale = 1.5;
let scale = baseResolution / resolution > maxScale ? maxScale : baseResolution / resolution;
scale = Math.max(minScale, Math.min(maxScale, scale));
@@ -176,7 +176,7 @@ GisOlStyle.prototype = {
const properties = feature.getProperties();
const overLayId = feature?.get('overLayId');
const overLayMinZoom = layer.get('overLayMinZoom') || '12';
const overLayMinZoom = layer.get('overLayMinZoom') || '10';
const overLayOffset = layer?.get('overLayOffset');
let findoOverlay = this.overlays[overLayId];
if (findoOverlay) {
@@ -193,7 +193,8 @@ GisOlStyle.prototype = {
if (typeof overlayHtml === 'string') {
//const sanitizedHtml = DOMPurify.sanitize(overlayHtml);
overlayContainer.innerHTML = sanitizedHtml;
//overlayContainer.innerHTML = sanitizedHtml;
overlayContainer.innerHTML = overlayHtml;
}
// React 컴포넌트일 경우 createPortal() 사용
else if (React.isValidElement(overlayHtml)) {
@@ -205,7 +206,7 @@ GisOlStyle.prototype = {
element: overlayContainer,
positioning: 'bottom-center',
stopEvent: false,
offset: overLayOffset ? overLayOffset : [0, 0], // 오프셋 설정 (위치 조정)
offset: overLayOffset ? overLayOffset : [80, 0], // 오프셋 설정 (위치 조정)
});
this.overlays[overLayId] = overlay;
@@ -274,7 +275,8 @@ GisOlStyle.prototype = {
let fontExtnColor = layer.get('fontExtnColor');
let fontExtnWdth = Number(layer.get('fontExtnWdth') || '1');
let fontIntnColor = layer.get('fontIntnColor');
let minViewSize = layer.get('minViewSize');
//let minViewSize = layer.get('minViewSize') || 13;
let minViewSize = 14;
if (feature == null) {
return;
}
@@ -282,14 +284,14 @@ GisOlStyle.prototype = {
let label = prop[layer.get('lblAtrbNm')];
let scaleFactor = 1 / resolution; // 해상도가 낮아질수록 글자 크기 증가
const zoom = GisApp.Module.core.map.getView().getZoom();
if (zoom < minViewSize - 3) {
if (zoom <= minViewSize) {
label = '';
}
let text = new Text({
stroke: new Stroke({
color: '#FFFFFF', // 흰색 테두리
width: fontExtnWdth,
}),
// stroke: new Stroke({
// color: '#FFFFFF', // 흰색 테두리
// width: fontExtnWdth,
// }),
fill: new Fill({
color: fontIntnColor,
width: fontExtnWdth,
@@ -0,0 +1,122 @@
// SearchInputText.tsx
"use client";
import { Input } from "@common/components/ui";
import { useEffect, useRef, useState } from "react";
interface SearchInputTextProps {
initValue?: string;
setValue: (val: string) => void;
setRtnData: (val: { fnm: string; fid: string }) => void;
searchList: { fnm: string; fid: string }[];
onEnter?: () => void;
className?: string;
}
export default function SearchInputText({
initValue,
setValue,
setRtnData,
searchList,
onEnter,
className,
}: SearchInputTextProps) {
const [showResults, setShowResults] = useState(false);
const [text, setText] = useState(initValue ?? "");
const [highlightIndex, setHighlightIndex] = useState(-1);
const wrapperRef = useRef(null);
useEffect(() => {
setText(initValue ?? "");
}, [initValue]);
useEffect(() => {
if (searchList.length > 0) {
setShowResults(true);
} else {
setShowResults(false);
}
setHighlightIndex(-1);
}, [searchList]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
wrapperRef.current &&
!(wrapperRef.current as any).contains(event.target)
) {
setShowResults(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [wrapperRef]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
setValue(e.target.value);
setHighlightIndex(-1);
};
const handleSelect = (item: { fnm: string; fid: string }) => {
setText(item.fnm);
setValue(item.fnm);
setRtnData(item);
setShowResults(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (highlightIndex >= 0 && highlightIndex < searchList.length) {
const selected = searchList[highlightIndex];
if (selected) handleSelect(selected);
} else if (onEnter) {
onEnter();
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIndex((prev) =>
prev < searchList.length - 1 ? prev + 1 : prev
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : -1));
}
};
return (
<div className="relative" ref={wrapperRef}>
<Input
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="시설명을 입력하세요"
className={
className + " rounded-lg border px-2 py-1 text-sm h-[29.5px]"
}
/>
{showResults && (
<div className="absolute z-10 mt-1 max-h-200 w-full overflow-auto rounded border bg-white shadow">
{searchList.length > 0 ? (
searchList.map((item, index) => (
<div
key={index}
className={`cursor-pointer px-3 py-1 hover:bg-gray-100 ${
highlightIndex === index ? "bg-blue-100" : ""
}`}
onClick={() => handleSelect(item)}
>
{item.fnm}
</div>
))
) : (
<div className="px-3 py-2 text-sm text-gray-500">
검색 결과가 없습니다.
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,2 @@
export * from "./procSearchBar";
export * from './searchBar'
@@ -0,0 +1,337 @@
"use client";
import { SelectBox, type SelectDataType } from "../../../ui";
import { useCallback, useEffect, useState } from "react";
import GisApp from "../lib/gis.core";
const geoserverUrl =
"/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&outputFormat=application/json";
const FACILITY_TYPE_URL_MAP: Record<string, string> = {
tb_il_swage_prcs_fclt: geoserverUrl, //하수처리시설
};
interface SearchBarProps {
gisMaps: React.RefObject<any>;
facilityTypes: { text: string; val: string }[];
}
export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
const [sidoValue, setSidoValue] = useState("0");
const [sigunValue, setSigunValue] = useState("0");
const [sidoData, setSidoData] = useState<SelectDataType[]>([{ text: "시도", val: "0" }]);
const [sigunData, setSigunData] = useState<SelectDataType[]>([{ text: "시군구", val: "0" }]);
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([{ text: "시군구", val: "0" }]);
const [facilityData, setFacilityData] = useState<SelectDataType[]>([{ text: "하수처리시설", val: "0" }]);
const [facilityValue, setFacilityValue] = useState("0");
const [facilityType, setFacilityType] = useState(
facilityTypes?.length === 1 && facilityTypes[0]
? facilityTypes[0].val
: "0",
);
const fetchSido = async () => {
const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_ctpv&outputFormat=application/json&propertyName=mctpv_korn_nm,mctpv_cd`;
const res = await fetch(url).then(res => res.json());
const list = res.features
.map((f: any) => ({
text: f.properties.mctpv_korn_nm,
val: f.properties.mctpv_cd,
}))
.sort((a: any, b: any) => {
// 커스텀 순서 정의
const customOrder = [
"서울특별시",
"부산광역시",
"대구광역시",
"인천광역시",
"광주광역시",
"대전광역시",
"울산광역시",
"세종특별자치시",
"경기도",
"강원특별자치도", // 경기도 다음에 배치
"충청북도",
"충청남도",
"전북특별자치도", // 충청남도 다음에 배치
"전라남도",
"경상북도",
"경상남도",
"제주특별자치도"
];
const indexA = customOrder.indexOf(a.text);
const indexB = customOrder.indexOf(b.text);
// 둘 다 커스텀 순서에 있으면 해당 순서로
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
// 하나만 커스텀 순서에 있으면 그것을 앞으로
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
// 둘 다 커스텀 순서에 없으면 기본 알파벳 순서
return a.text.localeCompare(b.text, 'ko');
});
setSidoData([{ text: "시도", val: "0" }, ...list]);
};
const fetchSigungu = async () => {
const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_sgg&outputFormat=application/json&propertyName=sgg_cd,sgg_korn_nm`;
const res = await fetch(url).then(res => res.json());
const list = res.features
.map((f: any) => ({
text: f.properties.sgg_korn_nm,
val: f.properties.sgg_cd,
}))
.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
setSigunData(list);
};
//시설명 콤보박스 조회
const fetchFacilitiesBySggNm = async (
sggCd: string,
sggNm: string,
sidoNm: string,
currentFacilityType?: string,
) => {
const type = currentFacilityType ?? facilityType;
if (!type || type === "0") return;
const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type];
if (!baseUrlTemplate) return;
const year = new Date().getFullYear().toString();
let url = baseUrlTemplate
.replace("{year}", year)
.replace("{sggCd}", sggCd)
.replace("{sidoNm}", encodeURIComponent(sidoNm))
.replace("{sggNm}", encodeURIComponent(sggNm));
let list: SelectDataType[] = [];
if (
type === "tb_il_swage_prcs_fclt" //하수처리시설
) {
const cqlFilter = `ctpv_nm='${encodeURIComponent(sidoNm)}' AND sgg_nm='${encodeURIComponent(sggNm)}'`;
url = `${url}&typeName=hasudo:${type}&cql_filter=${cqlFilter}`;
const res = await fetch(url);
const json = await res.json();
list = json.features
.map((f: any) => ({
text: f.properties.swtrmt_fclt_nm ?? "(이름없음)",
val: f.id,
}))
.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
} else if (
type === "tb_wr_bdst_ruse_fclt" // 재이용시설
) {
//재용에서 참조하는 하수처리시설 목록
const paramsRes = await fetch("/reuse-service/reuseFc/pubFc");
const paramsJson = await paramsRes.json();
const params = paramsJson.data;
const res = await fetch(url, {
method: "POST",
headers: {
accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.ruseUtztnFcltNm ?? "(이름없음)",
val: item.pbnstRuseFcltId,
}));
} else if (
type === "tb_wr_ruse_fclt_dmd_co" //수요처
) {
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.dmdCoNm ?? "(이름없음)",
val: item.dmdInfoId,
}));
} else if (
type === "tb_wr_tmpr_dmd_co" //잠재수요처
) {
// GET 방식
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.dmdCoNm ?? "(이름없음)",
val: item.fctryMngSn,
}));
} else {
// GET 방식
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.bdstNm ?? "(이름없음)",
val: item.bdstInfoSn,
}));
}
// 가나다순 정렬 후 set
const sorted = list.slice().sort((a, b) => a.text.localeCompare(b.text, 'ko'));
setFacilityData([{ text: "시도", val: "0" }, ...sorted]);
setFacilityValue("0");
};
//시도, 시군구 초기 데이터 조회
useEffect(() => {
fetchSido();
fetchSigungu();
}, []);
//시군구 데이터 초기화
const initializeSigunData = useCallback(
(sidoCd: string) => {
if (sidoCd === "0") {
setFilteredSigunData([{ text: "시군구", val: "0" }]);
setSigunValue("0");
return;
}
const prefix = sidoCd.substring(0, 2);
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
setSigunValue("0");
},
[sigunData],
);
//콤보박스 선택시 연관 콤보박스 처리
const onSelectValue = useCallback(
(val: string, type: string) => {
if (type === "ctpvCd") {
setSidoValue(val);
initializeSigunData(val);
setFacilityData([{ text: "하수처리시설", val: "0" }]);
setFacilityValue("0");
} else if (type === "sggCd") {
setSigunValue(val);
const sidoNm =
sidoData.find((item) => item.val === sidoValue)?.text || "";
const sggNm = sigunData.find((item) => item.val === val)?.text || "";
const sggCd = sigunData.find((item) => item.val === val)?.val || "";
if (sggNm && sidoNm)
fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, facilityType);
} else if (type === "facilityType") {
setFacilityType(val);
setFacilityValue("0");
setFacilityData([{ text: "하수처리시설", val: "0" }]);
const sidoNm =
sidoData.find((item) => item.val === sidoValue)?.text || "";
const sggNm =
sigunData.find((item) => item.val === sigunValue)?.text || "";
const sggCd =
sigunData.find((item) => item.val === sigunValue)?.val || "";
if (sggNm && sidoNm) fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, val);
} else if (type === "fcltCd") {
setFacilityValue(val);
// 하수처리시설일 때 바로 handleFeatureFocus 호출
if (val !== "0") {
handleFeatureFocus({
fid: val,
layerNm: "tb_il_swage_prcs_fclt", // 레이어 이름
});
}
}
},
[initializeSigunData, sigunData, sigunValue, sidoData, sidoValue],
);
const handleFeatureFocus = (data: any) => {
const map = gisMaps.current;
if (!map) {
console.warn("지도가 초기화되지 않았습니다.");
return;
}
// 항상 같은 select 객체 사용
const select = GisApp.Module.select as any;
if (!select) return;
// 1. 기존 선택 초기화
//select.resetSelectedFeature();
// 2. data가 OpenLayers Feature 인스턴스인지 확인
let foundFeature: any = null;
let featureId = data.fid;
map
.getLayers()
.getArray()
.forEach((layer: any) => {
const source = layer.getSource?.();
if (source?.getFeatureById) {
const feature = source.getFeatureById(String(featureId).trim());
if (feature) foundFeature = feature;
}
});
// 3. 피처 선택
if (foundFeature) {
const geometry = foundFeature.getGeometry();
if (geometry) {
const extent = geometry.getExtent();
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
constrainResolution: true,
nearest: true,
});
} else {
console.warn("geometry를 찾을 수 없습니다:", data);
}
} else {
console.warn("Feature를 찾을 수 없습니다:", data);
}
};
return (
<div className="grid grid-cols-4 gap-4">
<div className="col-span-4">
<div className="card card-border flex">
<div className="col-span-10 flex flex-row">
<SelectBox
labelClass="w-[40px]"
className="mr-10 w-[150px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={sidoData}
onSelectValue={(val: any) =>
onSelectValue(val.toString(), "ctpvCd")
}
value={sidoValue}
/>
<SelectBox
labelClass="w-[65px]"
className="mr-10 w-[150px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={filteredSigunData}
onSelectValue={(val: any) =>
onSelectValue(val.toString(), "sggCd")
}
value={sigunValue}
/>
<SelectBox
labelClass="w-[60px]"
className="mr-3 w-[180px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={facilityData}
onSelectValue={(val: any) =>
onSelectValue(val.toString(), "fcltCd")
}
value={facilityValue}
/>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,385 @@
"use client";
import { SelectBox, type SelectDataType } from "../../../ui";
import { Button } from "@common/components/ui";
import { useCallback, useEffect, useState } from "react";
import SearchInputText from "./SearchInputText";
import GisApp from "../lib/gis.core";
const FACILITY_TYPE_URL_MAP: Record<string, string> = {
tb_il_swage_prcs_fclt:
"/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_il_swage_prcs_fclt&outputFormat=application/json&propertyName=swtrmt_fclt_cd,swtrmt_fclt_nm&cql_filter=",
tb_wr_bdst_ruse_fclt:
"/reuse-service/reuseFc/pubDetailList/{year}/{sggCd}/1/1000/",
tb_wr_rnwtr_utztn_fclt:
"/reuse-service/reuseFc/rainyInfoList/1/1000/{sggCd}/{fcltyNm}",
tb_wr_rswtr_fclt:
"/reuse-service/reuseFc/waterreuseFc/1/1000/{sggCd}/{fcltyNm}",
tb_wr_htdfl_fclt:
"/reuse-service/drain/facilityList/1/1000/{sggCd}/{fcltyNm}",
tb_wr_ruse_fclt_dmd_co:
"/reuse-service/reuseFc/request/1/1000/{sggCd}/{fcltyNm}",
tb_wr_tmpr_dmd_co:
"/reuse-service/reuseFc/request/temp/1/1000/{sidoNm} {sggNm}/{fcltyNm}",
};
interface SearchBarProps {
gisMaps: React.RefObject<any>;
facilityTypes: { text: string; val: string }[];
onFeatureFocus: any;
}
export const SearchBar = ({
gisMaps,
facilityTypes,
onFeatureFocus,
}: SearchBarProps) => {
const [sidoValue, setSidoValue] = useState("0");
const [sigunValue, setSigunValue] = useState("0");
const [facilityType, setFacilityType] = useState(
facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0"
);
const [fcltyNm, setFcltyNm] = useState("");
const [searchList, setSearchList] = useState<{ fnm: string; fid: string }[]>(
[]
);
const [sidoData, setSidoData] = useState<SelectDataType[]>([
{ text: "시도", val: "0" },
]);
const [sigunData, setSigunData] = useState<SelectDataType[]>([
{ text: "시군구", val: "0" },
]);
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
{ text: "시군구", val: "0" },
]);
const fetchSido = async () => {
const res = await fetch("/cms-service/api/v1/code/cache/ADSTDSTRCD/Y");
const json = await res.json();
const sidoList = json
.filter((item: any) => item.lwpoCd?.length === 2)
.map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm }))
.sort((a: any, b: any) => {
// 커스텀 순서 정의
const customOrder = [
"서울특별시",
"부산광역시",
"대구광역시",
"인천광역시",
"광주광역시",
"대전광역시",
"울산광역시",
"세종특별자치시",
"경기도",
"강원특별자치도", // 경기도 다음에 배치
"충청북도",
"충청남도",
"전북특별자치도", // 충청남도 다음에 배치
"전라남도",
"경상북도",
"경상남도",
"제주특별자치도"
];
const indexA = customOrder.indexOf(a.text);
const indexB = customOrder.indexOf(b.text);
// 둘 다 커스텀 순서에 있으면 해당 순서로
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
// 하나만 커스텀 순서에 있으면 그것을 앞으로
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
// 둘 다 커스텀 순서에 없으면 기본 알파벳 순서
return a.text.localeCompare(b.text, 'ko');
});
const sigunList = json
.filter((item: any) => item.lwpoCd?.length > 2)
.map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm }))
.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
setSidoData([{ text: "시도", val: "0" }, ...sidoList]);
setSigunData(sigunList);
};
const initializeSigunData = useCallback(
(sidoCd: string) => {
if (sidoCd === "0") {
setFilteredSigunData([{ text: "시도", val: "0" }]);
setSigunValue("0");
return;
}
const prefix = sidoCd.substring(0, 2);
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
setSigunValue("0");
},
[sigunData]
);
useEffect(() => {
fetchSido();
}, []);
const onSelectValue = useCallback(
(val: string, type: string) => {
if (type === "ctpvCd") {
setSidoValue(val);
initializeSigunData(val);
setSearchList([]);
setFcltyNm("");
} else if (type === "sggCd") {
setSigunValue(val);
setSearchList([]);
setFcltyNm("");
} else if (type === "facilityType") {
setFacilityType(val);
setSearchList([]);
setFcltyNm("");
}
},
[initializeSigunData]
);
const handleSearch = async () => {
if (!fcltyNm) return alert("시설명을 입력하세요.");
const sidoNm = sidoData.find((item) => item.val === sidoValue)?.text || "";
const sggNm = sigunData.find((item) => item.val === sigunValue)?.text || "";
const type = facilityType;
const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type];
if (!baseUrlTemplate) return;
let url = baseUrlTemplate;
const year = new Date().getFullYear().toString();
url = url
.replace("{year}", year)
.replace("{sggCd}", sigunValue)
.replace("{sidoNm}", encodeURIComponent(sidoNm))
.replace("{sggNm}", encodeURIComponent(sggNm))
.replace("{fcltyNm}", encodeURIComponent(fcltyNm));
let list: SelectDataType[] = [];
if (type === "tb_il_swage_prcs_fclt") {
// 하수처리시설
let cqlFilter = `swtrmt_fclt_nm like '%${fcltyNm}%'`;
if (sggNm !== "시군구" && sggNm) cqlFilter = `sgg_nm='${sggNm}' and ` + cqlFilter;
if (sidoNm !== "시도" && sidoNm) cqlFilter = `ctpv_nm='${sidoNm}' and ` + cqlFilter;
url += encodeURIComponent(cqlFilter);
const res = await fetch(url);
const json = await res.json();
list = json.features.map((f: any) => ({
text: f.properties.swtrmt_fclt_nm ?? "(이름없음)",
val: f.id,
}));
} else if (
type === "tb_wr_bdst_ruse_fclt" // 재이용시설
) {
//재이용에서 참조하는 하수처리시설 목록
const procRes = await fetch("/reuse-service/reuseFc/pubFc");
const procJson = await procRes.json();
const procData = procJson.data;
const procResult = procData
.filter((code: string) => code.slice(0, 5) === sigunValue)
.map((code: string) => {
return code;
});
//처리시설명으로 처리시설코드 조회
let procUrl2 =
"/operation-service/prcsFclt/ruseInfo/1/1000/A/{sggCd}/0/1/SW/{fcltyNm}";
procUrl2 = procUrl2
.replace("{sggCd}", sigunValue)
.replace("{fcltyNm}", encodeURIComponent(fcltyNm));
const procRes2 = await fetch(procUrl2, {
method: "POST",
headers: {
accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(procResult),
});
const procJson2 = await procRes2.json();
const procResult2 = (procJson2.data || []).map((item: any) => item.fid);
//최종 처리시설코드로 재이용시설조회
const res = await fetch(url, {
method: "POST",
headers: {
accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(procResult2),
});
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.ruseUtztnFcltNm ?? "(이름없음)",
val: item.pbnstRuseFcltId,
}));
} else if (
type === "tb_wr_ruse_fclt_dmd_co" //수요처
) {
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.dmdCoNm ?? "(이름없음)",
val: item.dmdInfoId,
}));
} else if (
type === "tb_wr_tmpr_dmd_co" //잠재수요처
) {
// GET 방식
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.dmdCoNm ?? "(이름없음)",
val: item.fctryMngSn,
}));
} else {
// GET 방식
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.bdstNm ?? "(이름없음)",
val: item.bdstInfoSn,
}));
}
if (list.length === 0) {
alert("검색 결과가 없습니다.");
setSearchList([]);
setFcltyNm("");
return;
}
const newList = list.map((item: any) => {
const code = item.val;
const name = item.text;
return { fnm: name, fid: code };
});
setSearchList(newList);
};
//해당 시설물로 이동
const handleFeatureFocus = (data: any) => {
const map = gisMaps.current;
if (!map) {
console.warn("지도가 초기화되지 않았습니다.");
return;
}
// 항상 같은 select 객체 사용
const select = GisApp.Module.select as any;
if (!select) return;
// 1. 기존 선택 초기화
//select.resetSelectedFeature();
// 2. data가 OpenLayers Feature 인스턴스인지 확인
let foundFeature: any = null;
let featureId = data.fid;
map
.getLayers()
.getArray()
.forEach((layer: any) => {
const source = layer.getSource?.();
if (source?.getFeatureById) {
const feature = source.getFeatureById(String(featureId).trim());
if (feature) foundFeature = feature;
}
});
// 3. 피처 선택
if (foundFeature) {
const geometry = foundFeature.getGeometry();
if (geometry) {
const extent = geometry.getExtent();
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
constrainResolution: true,
nearest: true,
});
} else {
console.warn("geometry를 찾을 수 없습니다:", data);
}
} else {
console.warn("Feature를 찾을 수 없습니다:", data);
}
onFeatureFocus(foundFeature);
};
return (
<div className="grid grid-cols-4 gap-4">
<div className="col-span-4">
<div className="card card-border flex">
<div className="col-span-10 flex flex-row">
{facilityTypes.length > 1 && (
<SelectBox
labelClass="w-[120px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={[
{ text: "시설 구분", val: "0" },
...facilityTypes,
]}
onSelectValue={(val:any) =>
onSelectValue(val.toString(), "facilityType")
}
value={facilityType}
/>
)}
<SelectBox
labelClass="w-[40px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={sidoData}
onSelectValue={(val:any) => onSelectValue(val.toString(), "ctpvCd")}
value={sidoValue}
/>
<SelectBox
labelClass="w-[65px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={filteredSigunData}
onSelectValue={(val:any) => onSelectValue(val.toString(), "sggCd")}
value={sigunValue}
/>
<SearchInputText
initValue={fcltyNm}
setValue={(val: string = "") => {
setFcltyNm(val);
}}
setRtnData={(selected: { fnm: string; fid: string }) => {
// 선택된 항목이 있을 경우 onFeatureSelect 실행
const type = facilityType;
handleFeatureFocus({
layerNm: type,
...selected,
});
}}
searchList={searchList}
onEnter={handleSearch}
className="mr-5 w-[150px]"
/>
</div>
<div className="flex flex-row space-x-2">
<Button
color="primary"
className="mx-2 mt-1"
size="sm"
onClick={handleSearch}
>
조회
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -215,6 +215,8 @@ export function SdmRegForm({ form, onCheckboxChange, selectSidoData, selectSigun
useEffect(() => {
form.setValue('rgtr', '2d4b151d-1828-41e0-91d9-4cd28ea1cc7e');
if(updateList?.dmdInfoId){
form.reset(updateList)
if(updateList?.dmdInfoId.slice(10,14) !== '0000') {
@@ -322,7 +324,7 @@ export function SdmRegForm({ form, onCheckboxChange, selectSidoData, selectSigun
},
{
name: "bznmTotNo",
label: "종사업자등록번호",
label: "",
component: (field) => (
<FormUi
label="종사업자등록번호"
@@ -419,7 +421,7 @@ export function SdmRegForm({ form, onCheckboxChange, selectSidoData, selectSigun
name={name}
render={({ field }) => (
<div className="mb-4">
<label htmlFor={inputId} className="block text-gray-800 font-medium">
<label htmlFor={inputId} className="block text-gray-800 font-medium text-xl">
{label}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
@@ -432,7 +434,7 @@ export function SdmRegForm({ form, onCheckboxChange, selectSidoData, selectSigun
})}
{error?.message && (
<p id={errorId} role="alert" className="text-red-500 text-sm mt-1">
<p id={errorId} role="alert" className="text-red-500 text-lg mt-1">
{error.message}
</p>
)}
@@ -24,6 +24,7 @@ type SearchBaseType = {
rightClassName?: string;
leftClassName?: string;
leftSelectClassName?: string;
buttonOrder?: ("query" | "init" | "excel")[];
}
export type ReturnType = {
partValue: string;
@@ -55,7 +56,8 @@ function SearchBaseForm ({
isAdmin=false,
rightClassName,
leftClassName,
leftSelectClassName
leftSelectClassName,
buttonOrder
}: SearchBaseType) {
const [partValue, setPartValue] = useState<string>("A");
const [sidoValue, setSidoValue] = useState<string>("0");
@@ -67,6 +69,30 @@ function SearchBaseForm ({
const [middleRegionValue, setMiddleRegionValue] = useState<string>("0");
const [envPartValue, setEnvPartValue] = useState<string>("0");
const [resetTrigger, setResetTrigger] = useState(false);
const renderButton = (type: "query" | "init" | "excel") => {
switch (type) {
case "query":
return (
<Button className={btnClass} onClick={queryVal} key="query">
조회
</Button>
);
case "init":
return (
<Button className={btnClass} onClick={initVal} key="init">
초기화
</Button>
);
case "excel":
return (
<Button className={btnClass} color="green" onClick={excelDownload} key="excel">
{excelName ? excelName : "엑셀다운로드"}
</Button>
);
default:
return null;
}
};
const queryVal = () => {
console.log("조회버튼 클릭");
queryClick({
@@ -145,16 +171,8 @@ function SearchBaseForm ({
{leftChildren}
</SearchFormLeft>
<SearchFormRight className={rightClassName}>
<Button className={btnClass} color="green" onClick={excelDownload}>
{excelName?excelName:"엑셀다운로드"}
</Button>
<div>
<Button className={btnClass} onClick={initVal}>
초기화
</Button>
<Button className={btnClass} onClick={queryVal}>
조회
</Button>
{(buttonOrder ?? ["excel", "init", "query"]).map(renderButton)}
</div>
{rightChildren}
</SearchFormRight>
@@ -28,6 +28,7 @@ const SelectBox = React.forwardRef<
value?: string | number;
readOnly?: boolean; // readOnly 속성 추가
selectContentClass?: string;
labelClass?: string;
}
>(
(
@@ -40,6 +41,7 @@ const SelectBox = React.forwardRef<
value,
readOnly = false,
selectContentClass,
labelClass = "w-20 whitespace-nowrap text-xl text-gray-600",
},
ref,
) => {
@@ -66,7 +68,7 @@ const SelectBox = React.forwardRef<
return (
<div className={className + " flex items-center"} ref={ref}>
{label && (
<Label className="w-20 whitespace-nowrap text-xl text-gray-600">
<Label className={labelClass}>
{label}
</Label>
)}
@@ -8,7 +8,8 @@ interface TableUpperProps {
row? : number;
rowSpan?: number;
colSpan?: number;
visible?:boolean;
visible?:boolean;
widthPercent?: number;
}
const HeadMakeColSpan = ({ upHeadList }: { upHeadList: TableUpperProps[] }) => {
@@ -52,7 +53,7 @@ const HeadMakeColSpan = ({ upHeadList }: { upHeadList: TableUpperProps[] }) => {
colSpan={head.colSpan || 1}
rowSpan={head.rowSpan || 1}
style={{ whiteSpace: 'pre-line' }}
className="bg-gray-50"
className="bg-gray-50"
>
{head.title}
</TableHead>
@@ -30,6 +30,9 @@ type UiTableProps = {
boldArray?: number[];
resetSelection?: boolean;
index?: number;
curPage?: number;
headButton?: React.ReactNode | undefined;
widthFixed?: boolean;
};
type UiHeadProps = {
@@ -37,6 +40,9 @@ type UiHeadProps = {
title: string;
align?: string;
visible?: boolean;
row?: number;
widthPercent?: number;
rowSpan?: number;
};
export function UiTable({
@@ -53,8 +59,11 @@ export function UiTable({
boldArray = [],
resetSelection = false,
index = -1,
curPage = 1,
headButton = undefined,
widthFixed = false
}: UiTableProps) {
const [cur, setCur] = useState<number>(1);
const [cur, setCur] = useState<number>(curPage);
const [idx, setIdx] = useState<number>(index);
const [headerHeight, setHeaderHeight] = useState<number>(0);
const headerRef = useRef<HTMLTableSectionElement>(null);
@@ -78,9 +87,19 @@ export function UiTable({
}
}, [tableData, headlist]);
useEffect(() => {
setIdx(index);
}, [index])
useEffect(() => {
setCur(curPage);
}, [curPage])
const handlePageChange = (page: number) => {
setCur(page);
onPageChange(page);
if (index == 0)
setIdx(0)
};
const handleCellClick = (index: number, headId: string) => {
@@ -90,17 +109,40 @@ export function UiTable({
}
};
const getLeafCols = (list: UiHeadProps[]) => {
const maxRow = list?.reduce((max, head) => Math.max(max, head.row || 0), 0);
return list
.filter(h => h.visible !== false)
.filter(h => ((h.row ?? 0) + (h.rowSpan ?? 1) - 1) >= maxRow)
;
};
const leafCols = getLeafCols(headlist);
return (
<>
{headName ? (
<div className="card-header">
<h3 className="text-2xl font-medium text-label">{headName}</h3>
</div>
headButton ? (
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium text-label">{headName}</h3>
<>{headButton}</>
</div>
) : (
<div className="card-header">
<h3 className="text-2xl font-medium text-label">{headName}</h3>
</div>
)
) : (
<></>
)}
<div className={`card !p-0r !m-[1.2rem] ${className}`}>
<Table variant="secondary">
<Table variant="secondary" className={widthFixed?"table-fixed":""}>
{widthFixed?
<colgroup>
{leafCols.map(col => (
<col key={col.id} style={{ width: col.widthPercent ? `${col.widthPercent}%` : undefined }} />
))}
</colgroup>:""}
<TableHeader ref={headerRef} className="font-bold">
{children ? children : <HeadMakeColSpan upHeadList={headlist} />}
</TableHeader>
@@ -116,14 +158,13 @@ export function UiTable({
head.id.length > 1 &&
(boldArray.includes(index) ? (
<TableCell
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${
cellClick && idx === index
? "cursor-pointer !font-[1000]"
: cellClick
? "cursor-pointer"
: ""
} sticky z-40 bg-secondary !font-bold`}
style={{ top: `${headerHeight + index * 39}px` }}
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${cellClick && idx === index
? "cursor-pointer !font-[1000]"
: cellClick
? "cursor-pointer"
: ""
} sticky top-20 z-40 bg-secondary !font-bold whitespace-pre-line`}
style={{ top: `${headerHeight + index * 32 - 4}px` }}
onClick={() => handleCellClick(index, head.id)}
key={`cell-${head.id}-${index}`}
>
@@ -131,39 +172,38 @@ export function UiTable({
? String(el[head.id] ?? "")
: String(el[head.id]).includes(".")
? String(el[head.id])
.split(".")
.map((part, index) =>
index === 0
? part.replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
)
: part,
)
.join(".")
.split(".")
.map((part, index) =>
index === 0
? part.replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
)
: part,
)
.join(".")
: String(el[head.id] ?? "").replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
)}
/\B(?=(\d{3})+(?!\d))/g,
",",
)}
</TableCell>
) : (
<TableCell
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${
cellClick && idx === index
? "cursor-pointer !font-[1000]"
: cellClick
? "cursor-pointer"
: ""
} mt-[${boldArray.length * 40}px]`}
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${cellClick && idx === index
? "cursor-pointer !font-[1000]"
: cellClick
? "cursor-pointer"
: ""
} mt-[${boldArray.length * 40}px] whitespace-pre-line`}
onClick={() => handleCellClick(index, head.id)}
key={`cell-${head.id}-${index}`}
>
{
//체크박스 유형애서 readOnly가 필요한 경우를 위해서 ChkClass 를 사용해서 readOnly 처리함.
typeof el[head.id] === "object" &&
el[head.id] !== null &&
"chk" in (el[head.id] as object) &&
"readonly" in (el[head.id] as object) ? (
el[head.id] !== null &&
"chk" in (el[head.id] as object) &&
"readonly" in (el[head.id] as object) ? (
<Checkbox
checked={
(el[head.id] as ChkClass)?.chk === true
@@ -199,9 +239,9 @@ export function UiTable({
.map((part, index) =>
index === 0
? part.replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
)
/\B(?=(\d{3})+(?!\d))/g,
",",
)
: part,
)
.join(".")
@@ -31,7 +31,7 @@ const TableHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn("sticky top-0 z-10 bg-secondary [&_tr]:border-b", className)}
className={cn("sticky -top-3 z-10 bg-secondary [&_tr]:border-b", className)}
{...props}
/>
));