Files
Aislo/B04_PreProcess/B04_PreProcess_UI_FlowRamp.ts
T
eomsangdonandClaude Opus 5.5 281c71b55b refactor(logic): 348-1 순수 계산 TS 49 파일 <페이지>/logic/ 로 옮김 — 이름 앞머리 그대로 · 동작 고침 없음
- 옮김: B04 2 · B05 23 · B06 11 · A00_Common/design 13 (9,299 줄) · 이미 logic/ 에 있던 16 파일 그대로
- 안 옮김: 묶음 입구(package.json build:*) · 파이썬 · 순수지만 계산 아닌 8 (보기 결선 · 콜백 · API 타입 · 갈아끼움 알림판)
- import 266 줄(142 파일) · 시험 · 주석 · 짝 표시 문자열 경로 고침 · B05 · B06 CLAUDE.md 짝 줄 logic/
- 시험 도우미 새 경로: 가짜 require 열쇠 · B05()/B06() 부름 · 형제 import 자리 · tsc 묶어 돌리는 시험 · 정규식 · rglob
- design_view_cross 줄 한도 시험 685 → 688 (긴 import 경로 줄바꿈 3줄)
- 확인: typecheck 0 · 서버 묶음 넷(corridor · server-calc · b07-extent · b07-note) 다시 지어 코드 같음(//#region 경로 주석만 다름) · 관련 시험 270 파일 — 남은 실패 19 는 옮기기 전과 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 13:44:50 +09:00

172 lines
7.1 KiB
TypeScript

/* =============================================================================
* 도로 유입 강도 색띠 (B04 2D 지도 · B05 배수유역도 공용)
*
* 계획선 **1m 구간마다** 그 자리로 모이는 상류 면적을 색으로 칠한다. 두 화면이 같은 값을
* 같은 색으로 보여야 대조가 되므로 색띠와 정규화 규칙을 여기 한 곳에 둔다.
*
* 색 단계는 로그 스케일이다. 계곡 한 지점이 사면보다 수백 배 크기 때문에 선형으로 칠하면
* 몇 점만 빨갛고 나머지는 전부 파랑으로 뭉친다(2026-08-01 사용자 지시).
* ========================================================================== */
import "@ui/ui_template_flow_legend.css";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette";
import type { RoutePoint } from "./logic/B04_PreProcess_UI_RouteSamples";
/** 강도 색띠 — 파랑(적음) → 빨강(많음). 정의처는 `ui_template_theme.css`. */
const RAMP_TOKENS: ReadonlyArray<[name: string, fallback: string]> = [
["--map-flow-ramp-0", "#2563eb"],
["--map-flow-ramp-1", "#06b6d4"],
["--map-flow-ramp-2", "#22c55e"],
["--map-flow-ramp-3", "#eab308"],
["--map-flow-ramp-4", "#f97316"],
["--map-flow-ramp-5", "#dc2626"],
];
/** 강도 선 굵기(px). 계획선(2.4)보다 굵어야 그 위에 얹힌 것으로 읽힌다. */
export const STRENGTH_LINE_WIDTH = 5;
/** `#rrggbb` → [r,g,b]. 색띠는 hex로만 정의한다(보간을 위해). */
function parseHex(color: string): [number, number, number] {
const hex = color.trim().replace("#", "");
const full =
hex.length === 3
? hex
.split("")
.map((c) => c + c)
.join("")
: hex;
const value = Number.parseInt(full.slice(0, 6), 16);
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}
/** 0~1을 색띠 위에서 보간한다. */
export function rampColor(t: number): string {
const clamped = Math.min(1, Math.max(0, t));
const last = RAMP_TOKENS.length - 1;
const position = clamped * last;
const low = Math.min(last, Math.floor(position));
const high = Math.min(last, low + 1);
const ratio = position - low;
const a = parseHex(themeColor(RAMP_TOKENS[low][0], RAMP_TOKENS[low][1]));
const b = parseHex(themeColor(RAMP_TOKENS[high][0], RAMP_TOKENS[high][1]));
const mix = (index: number): number => Math.round(a[index] + (b[index] - a[index]) * ratio);
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
}
/** 색띠 위치를 정하는 정규화(로그 스케일). 최대가 0이면 전부 0. */
export function normalizeStrength(value: number, maximum: number): number {
if (maximum <= 0 || value <= 0) return 0;
return Math.log1p(value) / Math.log1p(maximum);
}
/** 백엔드가 준 [누가거리, 면적] 목록을 인덱스=누가거리(m) 배열로 편다. */
export function buildStrengthArray(profile: ReadonlyArray<readonly [number, number]>): {
strength: Float64Array<ArrayBuffer>;
maximum: number;
} {
const length = profile.reduce((max, [chainage]) => Math.max(max, chainage), 0);
const strength = new Float64Array(Math.floor(length) + 1);
profile.forEach(([chainage, area]) => {
const index = Math.round(chainage);
if (index >= 0 && index < strength.length) strength[index] = area;
});
// 노선이 길면 점이 수만 개가 되므로 spread(Math.max(...))로 최대를 구하지 않는다.
const maximum = strength.reduce((max, value) => (value > max ? value : max), 0);
return { strength, maximum };
}
/** 1m 재표본 계획선 위에 강도 색을 칠한다. 화면 변환은 호출부가 넘긴다. */
export function drawStrengthLine(
context: CanvasRenderingContext2D,
samples: ReadonlyArray<RoutePoint>,
strength: Float64Array,
maximum: number,
toScreen: (point: RoutePoint) => [number, number],
): void {
if (samples.length < 2 || strength.length === 0) return;
context.save();
context.lineWidth = STRENGTH_LINE_WIDTH;
context.lineCap = "round";
const limit = Math.min(strength.length, samples.length - 1);
for (let index = 0; index < limit; index += 1) {
const value = strength[index];
if (value <= 0) continue; // 유입 없는 구간은 계획선 원래 색을 그대로 둔다
const [x0, y0] = toScreen(samples[index]);
const [x1, y1] = toScreen(samples[index + 1]);
context.strokeStyle = rampColor(normalizeStrength(value, maximum));
context.beginPath();
context.moveTo(x0, y0);
context.lineTo(x1, y1);
context.stroke();
}
context.restore();
}
/** 범례 눈금 수(맨 위=최대, 맨 아래=0). 5개면 로그 눈금이 촘촘하지도 성기지도 않다. */
const LEGEND_TICKS = 5;
export interface FlowLegend {
/** 지도 뷰포트에 append할 요소. 위치는 CSS가 정한다(우측 세로). */
root: HTMLElement;
/** 최대 유입면적(㎡)을 넘겨 눈금을 갱신한다. 0 이하이거나 `visible=false`면 감춘다. */
update: (maxAreaM2: number, visible: boolean) => void;
}
/** 면적을 사람이 읽는 문구로. 1ha 이상은 ha로 줄인다. */
function formatLegendArea(areaM2: number): string {
if (areaM2 >= 10000) return `${(areaM2 / 10000).toFixed(1)}ha`;
if (areaM2 >= 1000) return `${Math.round(areaM2 / 100) / 10}k㎡`;
return `${Math.round(areaM2)}㎡`;
}
/**
* 유입 강도 색띠 범례. 색은 화면과 **같은 색띠**를, 눈금은 **같은 로그 정규화**를 쓴다 —
* 다른 규칙으로 그리면 범례가 오히려 오독을 만든다(2026-08-02 사용자 지시).
*
* 색칠은 `normalizeStrength()`가 log1p 비율을 쓰므로, 막대 위치 t에 해당하는 값은
* `expm1(t · log1p(max))`로 되돌린다.
*/
export function createFlowLegend(): FlowLegend {
const root = document.createElement("div");
root.className = "ui-flow-legend";
root.hidden = true;
const title = document.createElement("span");
title.className = "ui-flow-legend__title";
title.textContent = ui_locales.B04_Surface_Flow_Legend_Title[currentLanguageIndex];
const bar = document.createElement("div");
bar.className = "ui-flow-legend__bar";
const ticks = document.createElement("div");
ticks.className = "ui-flow-legend__ticks";
const tickLabels = Array.from({ length: LEGEND_TICKS }, () => {
const label = document.createElement("span");
ticks.append(label);
return label;
});
root.append(title, bar, ticks);
return {
root,
update(maxAreaM2, visible) {
root.hidden = !visible || !(maxAreaM2 > 0);
if (root.hidden) return;
// 색띠는 화면과 같은 색을 그대로 쓴다(아래가 적음 → 위가 많음).
const stops = Array.from({ length: 11 }, (_, index) => {
const ratio = index / 10;
return `${rampColor(ratio)} ${(ratio * 100).toFixed(0)}%`;
});
bar.style.background = `linear-gradient(to top, ${stops.join(", ")})`;
const span = Math.log1p(maxAreaM2);
tickLabels.forEach((label, index) => {
// 위에서부터 최대 → 0 순으로 적는다(막대와 같은 방향).
const ratio = 1 - index / (LEGEND_TICKS - 1);
label.textContent = formatLegendArea(Math.expm1(ratio * span));
});
},
};
}