- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
231 lines
11 KiB
TypeScript
231 lines
11 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { ChevronDown, ChevronUp, Settings2, X } from 'lucide-react';
|
|
|
|
export * from './legendScale';
|
|
import { bandLabels, classBreaks, type LegendOverride, type LegendScale } from './legendScale';
|
|
|
|
// The scales currently drawn on the map, so the PNG export composites the same
|
|
// legend the user sees (there is a single map instance).
|
|
let activeScales: LegendScale[] = [];
|
|
export const setActiveLegendScales = (scales: LegendScale[]) => { activeScales = scales; };
|
|
export const getActiveLegendScales = () => activeScales;
|
|
|
|
export const Legend: React.FC<{
|
|
scales: LegendScale[];
|
|
note?: string;
|
|
/** Opens the editor for one scale (title); omit to hide the edit buttons. */
|
|
onEdit?: (title: string) => void;
|
|
}> = ({ scales, note, onEdit }) => {
|
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
if (scales.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: '24px',
|
|
right: '12px',
|
|
zIndex: 10,
|
|
background: 'rgba(255, 255, 255, 0.94)',
|
|
padding: isCollapsed ? '4px 8px' : '6px 10px',
|
|
borderRadius: 'var(--r-sm)',
|
|
border: '1px solid var(--border)',
|
|
fontSize: '10px',
|
|
boxShadow: 'var(--shadow-md)',
|
|
width: isCollapsed ? 'auto' : '200px',
|
|
backdropFilter: 'blur(4px)',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
<div
|
|
onClick={() => setIsCollapsed((v) => !v)}
|
|
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', gap: '6px' }}
|
|
title={isCollapsed ? '범례 펼치기' : '범례 접기'}
|
|
>
|
|
<span style={{ fontWeight: 700, fontSize: '10px', color: 'var(--text-secondary)' }}>
|
|
{isCollapsed ? `범례 (${scales.map((s) => s.title).join('·')})` : '관망 범례'}
|
|
</span>
|
|
<button
|
|
style={{ border: 'none', background: 'none', padding: 0, cursor: 'pointer', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}
|
|
>
|
|
{isCollapsed ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
</button>
|
|
</div>
|
|
|
|
{!isCollapsed && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '6px' }}>
|
|
{note && <div style={{ fontSize: '9px', color: 'var(--text-muted)' }}>{note}</div>}
|
|
{scales.map((s) => {
|
|
const labels = bandLabels(s.breaks);
|
|
return (
|
|
<div key={s.title}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '3px' }}>
|
|
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{s.title}</span>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '9px', color: 'var(--text-muted)' }}>
|
|
{s.unit}
|
|
{onEdit && (
|
|
<button
|
|
onClick={() => onEdit(s.title)}
|
|
title={`${s.title} 범례 설정 (구간·색·값에 따른 크기)`}
|
|
aria-label={`${s.title} 범례 설정`}
|
|
style={{ border: 'none', background: 'none', padding: 0, cursor: 'pointer', color: 'var(--text-muted)', display: 'flex' }}
|
|
>
|
|
<Settings2 size={11} />
|
|
</button>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '1px' }}>
|
|
{s.colors.map((c, i) => (
|
|
<div key={i} style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ background: c, height: '7px' }} />
|
|
<div style={{ marginTop: '2px', fontSize: '8.5px', fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden' }}>
|
|
{labels[i]}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Composite the active legend onto a canvas for PNG image export (Q4).
|
|
*/
|
|
export function drawLegendOnCanvas(
|
|
ctx: CanvasRenderingContext2D,
|
|
canvasWidth: number,
|
|
canvasHeight: number,
|
|
dpr: number,
|
|
scales: LegendScale[] = activeScales
|
|
) {
|
|
if (scales.length === 0) return;
|
|
const boxWidth = 220 * dpr;
|
|
const sectionH = 45 * dpr;
|
|
const boxHeight = (24 + 45 * scales.length) * dpr;
|
|
const margin = 20 * dpr;
|
|
const x = canvasWidth - boxWidth - margin;
|
|
const y = canvasHeight - boxHeight - margin;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.94)';
|
|
ctx.strokeStyle = '#cbd5e1';
|
|
ctx.lineWidth = 1 * dpr;
|
|
ctx.beginPath();
|
|
if (ctx.roundRect) {
|
|
ctx.roundRect(x, y, boxWidth, boxHeight, 6 * dpr);
|
|
} else {
|
|
ctx.rect(x, y, boxWidth, boxHeight);
|
|
}
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = '#1e293b';
|
|
ctx.font = `bold ${11 * dpr}px sans-serif`;
|
|
ctx.fillText('관망 범례', x + 10 * dpr, y + 16 * dpr);
|
|
|
|
scales.forEach((s, si) => {
|
|
const startY = y + 33 * dpr + si * sectionH;
|
|
ctx.fillStyle = '#334155';
|
|
ctx.font = `600 ${9.5 * dpr}px sans-serif`;
|
|
ctx.fillText(s.title, x + 10 * dpr, startY);
|
|
ctx.fillStyle = '#64748b';
|
|
ctx.font = `${8.5 * dpr}px sans-serif`;
|
|
ctx.fillText(s.unit, x + boxWidth - 10 * dpr - ctx.measureText(s.unit).width, startY);
|
|
|
|
const barY = startY + 4 * dpr;
|
|
const barW = (boxWidth - 20 * dpr) / s.colors.length;
|
|
const barH = 7 * dpr;
|
|
const labels = bandLabels(s.breaks);
|
|
ctx.font = `${7.5 * dpr}px monospace`;
|
|
s.colors.forEach((c, i) => {
|
|
const bx = x + 10 * dpr + i * barW;
|
|
ctx.fillStyle = c;
|
|
ctx.fillRect(bx, barY, barW - 1 * dpr, barH);
|
|
ctx.fillStyle = '#64748b';
|
|
const tw = ctx.measureText(labels[i]).width;
|
|
ctx.fillText(labels[i], bx + (barW - tw) / 2, barY + barH + 9 * dpr);
|
|
});
|
|
});
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/** 범례 설정: class edges, colours, equal-interval / quantile, reverse, size by value. */
|
|
export const LegendEditor: React.FC<{
|
|
scale: LegendScale;
|
|
defaultScale: LegendScale;
|
|
/** Current values of the scale's property (for equal interval / quantile). */
|
|
values: number[];
|
|
onSave: (override: LegendOverride | null) => void;
|
|
onClose: () => void;
|
|
}> = ({ scale, defaultScale, values, onSave, onClose }) => {
|
|
const n = scale.colors.length;
|
|
const [breaks, setBreaks] = useState<string[]>(scale.breaks.map((b) => String(b)));
|
|
const [colors, setColors] = useState<string[]>([...scale.colors]);
|
|
const [sizeBy, setSizeBy] = useState(Boolean(scale.sizeByValue));
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const vals = scale.absolute ? values.map(Math.abs) : values;
|
|
const auto = (method: 'equal' | 'quantile') => {
|
|
const b = classBreaks(vals, n, method);
|
|
if (!b) return setMsg('값이 없거나 모두 같아 자동 구간을 만들 수 없습니다. 해석을 먼저 실행하세요.');
|
|
setBreaks(b.map(String));
|
|
setMsg(null);
|
|
};
|
|
const save = () => {
|
|
const nums = breaks.map(Number);
|
|
if (nums.some((x) => !Number.isFinite(x))) return setMsg('구간 값은 숫자여야 합니다.');
|
|
for (let i = 2; i < nums.length; i++) if (!(nums[i] > nums[i - 1])) return setMsg('구간 경계는 커지는 순서여야 합니다.');
|
|
onSave({ unit: scale.unit, breaks: nums, colors, sizeByValue: sizeBy });
|
|
onClose();
|
|
};
|
|
const labels = bandLabels(breaks.map(Number));
|
|
const inp: React.CSSProperties = { width: 64, height: 22, fontSize: 11, fontFamily: 'var(--font-mono)' };
|
|
return (
|
|
<div style={{ position: 'fixed', inset: 0, zIndex: 1050, background: 'rgba(15,23,42,0.35)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
|
|
<div role="dialog" aria-label="범례 설정" onClick={(e) => e.stopPropagation()}
|
|
style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', boxShadow: 'var(--shadow-modal)', width: 360, fontSize: 12 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'var(--bg-subtle)', fontWeight: 600 }}>
|
|
<span>범례 설정: {scale.title} ({scale.unit})</span>
|
|
<button className="btn btn-ghost" style={{ padding: 3 }} onClick={onClose} aria-label="닫기"><X size={14} /></button>
|
|
</div>
|
|
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
<div style={{ color: 'var(--text-secondary)' }}>
|
|
{scale.absolute ? '절댓값 기준. ' : ''}첫 칸은 표시용 하한이고, 나머지 값이 각 색 구간의 시작 경계입니다.
|
|
</div>
|
|
{colors.map((c, i) => (
|
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<input type="color" value={c} onChange={(e) => setColors(colors.map((x, k) => (k === i ? e.target.value : x)))} style={{ width: 30, height: 22, padding: 0, border: '1px solid var(--border)' }} aria-label={`${i + 1}번째 색`} />
|
|
<input type="number" value={breaks[i]} onChange={(e) => setBreaks(breaks.map((x, k) => (k === i ? e.target.value : x)))} style={inp} aria-label={`${i + 1}번째 경계`} />
|
|
<span style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>{labels[i]}</span>
|
|
</div>
|
|
))}
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
|
<button className="btn btn-secondary" style={{ height: 24, fontSize: 11 }} onClick={() => auto('equal')}>등간격</button>
|
|
<button className="btn btn-secondary" style={{ height: 24, fontSize: 11 }} onClick={() => auto('quantile')}>등분위</button>
|
|
<button className="btn btn-secondary" style={{ height: 24, fontSize: 11 }} onClick={() => setColors([...colors].reverse())}>색상 반전</button>
|
|
<button className="btn btn-secondary" style={{ height: 24, fontSize: 11 }} onClick={() => { setBreaks(defaultScale.breaks.map(String)); setColors([...defaultScale.colors]); setSizeBy(false); }}>기본값</button>
|
|
</div>
|
|
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
<input type="checkbox" checked={sizeBy} onChange={(e) => setSizeBy(e.target.checked)} />
|
|
값에 따라 크기 표시 (절점 반지름 / 관로 굵기)
|
|
</label>
|
|
{msg && <div style={{ color: 'var(--crit)' }}>{msg}</div>}
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '10px 14px', borderTop: '1px solid var(--border)', background: 'var(--bg-subtle)' }}>
|
|
<button className="btn btn-secondary" style={{ fontSize: 11 }} onClick={() => { onSave(null); onClose(); }}>설정 지우기</button>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button className="btn btn-secondary" style={{ fontSize: 11 }} onClick={onClose}>취소</button>
|
|
<button className="btn btn-primary" style={{ fontSize: 11 }} onClick={save}>적용</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|