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 ? : }
{!isCollapsed && (
{note &&
{note}
}
{scales.map((s) => {
const labels = bandLabels(s.breaks);
return (
{s.title}
{s.unit}
{onEdit && (
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' }}
>
)}
{s.colors.map((c, 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})
{ onSave(null); onClose(); }}>설정 지우기
취소
적용
);
};