Files
SPipe/src/Legend.tsx
T
bjkim1 21ebb16fb7 SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
2026-10-30 11:00:46 +09:00

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>
);
};