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 (
setIsCollapsed((v) => !v)} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', gap: '6px' }} title={isCollapsed ? '범례 펼치기' : '범례 접기'} > {isCollapsed ? `범례 (${scales.map((s) => s.title).join('·')})` : '관망 범례'}
{!isCollapsed && (
{note &&
{note}
} {scales.map((s) => { const labels = bandLabels(s.breaks); return (
{s.title} {s.unit} {onEdit && ( )}
{s.colors.map((c, i) => (
{labels[i]}
))}
); })}
)}
); }; /** * 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(scale.breaks.map((b) => String(b))); const [colors, setColors] = useState([...scale.colors]); const [sizeBy, setSizeBy] = useState(Boolean(scale.sizeByValue)); const [msg, setMsg] = useState(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 (
e.stopPropagation()} style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', boxShadow: 'var(--shadow-modal)', width: 360, fontSize: 12 }}>
범례 설정: {scale.title} ({scale.unit})
{scale.absolute ? '절댓값 기준. ' : ''}첫 칸은 표시용 하한이고, 나머지 값이 각 색 구간의 시작 경계입니다.
{colors.map((c, i) => (
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}번째 색`} /> setBreaks(breaks.map((x, k) => (k === i ? e.target.value : x)))} style={inp} aria-label={`${i + 1}번째 경계`} /> {labels[i]}
))}
{msg &&
{msg}
}
); };