From 90194b2849fdc55ee7e2695ff641ff0a102bb1a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 19:32:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EC=B0=BD=20=E2=80=94=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EB=9D=BC=EB=B2=A8=20=C2=B7=20=ED=98=B8=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=ED=8C=90=EC=A0=95=20=ED=95=A8?= =?UTF-8?q?=EC=88=98(22-2=20=C2=B7=2022-3=201=EC=B0=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측점 라벨 글자 상자를 그릴 때 기록(drawStationTicks 의 onLabel · B04 지도 · 배수유역도 동작 그대로) - stationAtScreen 이 눈금 다음 라벨 상자를 봄 · 솎여 안 보이는 눈금은 안 잡음 - 곡선 호 점열(curveArcVertices)과 점-호 판정(arcAtScreen) 더함 - 시험 resources/tester/test_b05_routeedit_pick.py 8건 - RouteEdit.ts 배선은 sub6 나누기 뒤 새 파일에 잇는 다음 커밋 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../B04_PreProcess_UI_MapOverlays.ts | 13 ++ B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts | 24 +++ B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 91 ++++++++++ .../B05_Profile_UI_RouteEdit_Render.ts | 11 +- .../tester/helper_b05_routeedit_pick.cjs | 161 ++++++++++++++++++ resources/tester/test_b05_routeedit_pick.py | 71 ++++++++ 6 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 resources/tester/helper_b05_routeedit_pick.cjs create mode 100644 resources/tester/test_b05_routeedit_pick.py diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index b0f8d913..73543179 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -208,6 +208,18 @@ export interface StationTickOptions { /** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. * 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */ uprightRad?: number; + /** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고, + * 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */ + onLabel?: (box: StationLabelBox) => void; +} + +/** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */ +export interface StationLabelBox { + chainage: number; + x: number; + y: number; + half: number; + uprightRad: number; } export function drawStationTicks( @@ -299,6 +311,7 @@ export function drawStationTicks( context.stroke(); } drawn.push({ x: lx, y: ly, half }); + options.onLabel?.({ chainage, x: lx, y: ly, half, uprightRad: options.uprightRad ?? 0 }); context.save(); if (options.uprightRad) { context.translate(lx, ly); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts index 4a35d75b..107591c4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts @@ -172,6 +172,30 @@ function arcPoints(center: Vertex, start: Vertex, end: Vertex, clockwise: boolea return points; } +/** 곡선의 호를 점열로 — 시작점 · 원호 위 점 · 끝점. 그려지는 선(`buildEditedPolyline`)과 같은 + * 5° 걸음이라 「호를 눌렀나」 판정이 눈에 보이는 선과 어긋나지 않는다. 호는 늘 180° 미만이라 + * 두 접선점 사이 **짧은 쪽**으로 돈다(도는 방향을 따로 안 받는다). */ +export function curveArcVertices(curve: EditedCurve): Vertex[] { + const toStart = unit(curve.apex, curve.start); + const toEnd = unit(curve.apex, curve.end); + const bisector: Vertex = [toStart[0] + toEnd[0], toStart[1] + toEnd[1]]; + const bisectorLength = Math.hypot(bisector[0], bisector[1]); + const half = Math.sin((curve.inner_angle_deg * RAD_PER_DEG) / 2); + if (bisectorLength <= 1e-9 || !(half > 1e-9)) return [curve.start, curve.end]; + const centerDistance = curve.radius_m / half; + const center: Vertex = [ + curve.apex[0] + (bisector[0] / bisectorLength) * centerDistance, + curve.apex[1] + (bisector[1] / bisectorLength) * centerDistance, + ]; + const sweep = Math.atan2( + (curve.start[0] - center[0]) * (curve.end[1] - center[1]) - + (curve.start[1] - center[1]) * (curve.end[0] - center[0]), + (curve.start[0] - center[0]) * (curve.end[0] - center[0]) + + (curve.start[1] - center[1]) * (curve.end[1] - center[1]), + ); + return [curve.start, ...arcPoints(center, curve.start, curve.end, sweep < 0), curve.end]; +} + /** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */ export interface EditedNode { inner_angle_deg: number | null; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index 8f16a823..0badb0dd 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -15,6 +15,8 @@ import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { StationLabelBox } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; +import { curveArcVertices, type EditedCurve } from "./B05_Profile_UI_RouteEdit_Curve"; /** 배수유역도와 같은 값 — 한 번에 얼마나 확대·축소할지. */ const ZOOM_IN_FACTOR = 1.15; @@ -189,6 +191,41 @@ export function handleAtScreen( return best; } +/** 호(곡선 부분)를 눌렀다고 볼 거리(px) — 노선 선 굵기보다 넉넉히. */ +export const ARC_HIT_PX = 8; + +/** 화면 좌표에 가장 가까운 **호**를 가진 곡선의 노드 번호(`node_first`). 문턱 밖이면 -1. + * + * 호는 그려지는 선과 같은 점열(`curveArcVertices`)을 화면으로 옮겨 선분 거리로 잰다. + * 손잡이(접선점)는 호의 양 끝이라 이 판정에도 걸린다 — 손잡이를 먼저 보는 것은 부르는 쪽 몫. */ +export function arcAtScreen( + curves: ReadonlyArray, + toScreen: ScreenOf, + px: number, + py: number, + hitPx: number = ARC_HIT_PX, +): number { + let best = -1; + let bestDistance = hitPx; + for (const curve of curves) { + const arc = curveArcVertices(curve).map(([x, y]) => toScreen([x, y])); + for (let index = 0; index < arc.length - 1; index += 1) { + const [ax, ay] = arc[index]; + const [bx, by] = arc[index + 1]; + const dx = bx - ax; + const dy = by - ay; + const lengthSquared = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared)); + const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py); + if (distance <= bestDistance) { + bestDistance = distance; + best = curve.node_first; + } + } + } + return best; +} + /** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. 없으면 -1. */ export function segmentAtScreen( points: Array<[number, number]>, @@ -275,6 +312,59 @@ export function stationAtScreen( px: number, py: number, maxPx: number, + labels: ReadonlyArray = [], +): number | null { + // 라벨이 그려진 측점만 눈금도 보인다(`drawStationTicks` 가 성기게 솎는다) — 안 보이는 눈금이 + // 호·등고선 누름을 가로채지 않게 한다. 라벨 정보가 없으면 전부 본다. + const shown = labels.length + ? new Set(labels.map((box) => Math.round(box.chainage / intervalM))) + : null; + const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown); + // 눈금과 라벨이 겹치면 눈금이 먼저다 — 라벨은 눈금을 못 잡았을 때만 본다. + return tick ?? stationLabelAtScreen(labels, px, py); +} + +/** 그려 둔 측점 라벨 글자 상자 — 그릴 때마다 `Render` 가 새로 적는다. 창(캔버스)마다 따로. */ +const labelBoxes = new WeakMap>(); +export const setStationLabelBoxes = ( + canvas: HTMLCanvasElement, + boxes: ReadonlyArray, +): void => void labelBoxes.set(canvas, boxes); +export const stationLabelBoxes = (canvas: HTMLCanvasElement): ReadonlyArray => + labelBoxes.get(canvas) ?? []; + +/** 라벨 글자 상자 안을 눌렀나 — 상자는 가운데를 축으로 `uprightRad` 만큼 돌아 있어, 눌린 자리를 + * 거꾸로 돌려 세운 뒤 가로 반폭 · 세로 반폭(8) 안인지 본다. 여럿이면 가운데가 가까운 것. */ +export function stationLabelAtScreen( + labels: ReadonlyArray, + px: number, + py: number, +): number | null { + let best: number | null = null; + let bestDistance = Infinity; + for (const box of labels) { + const dx = px - box.x; + const dy = py - box.y; + const cos = Math.cos(-box.uprightRad); + const sin = Math.sin(-box.uprightRad); + if (Math.abs(dx * cos - dy * sin) > box.half || Math.abs(dx * sin + dy * cos) > 8) continue; + const distance = Math.hypot(dx, dy); + if (distance < bestDistance) { + bestDistance = distance; + best = box.chainage; + } + } + return best; +} + +function stationTickAtScreen( + line: Array<[number, number]>, + toScreen: ScreenOf, + intervalM: number, + px: number, + py: number, + maxPx: number, + shown: ReadonlySet | null, ): number | null { if (line.length < 2 || !(intervalM > 0)) return null; const cumulative: number[] = [0]; @@ -289,6 +379,7 @@ export function stationAtScreen( let bestDistance = maxPx; let cursor = 1; for (let chainage = 0; chainage <= total; chainage += intervalM) { + if (shown && !shown.has(Math.round(chainage / intervalM))) continue; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; const back = line[cursor - 1]; const front = line[cursor]; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 7e603d50..730bb509 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -20,9 +20,12 @@ import { type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; -import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; +import { + drawStationTicks, + type StationLabelBox, +} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; -import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input"; +import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdit_Input"; import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross"; import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame"; import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure"; @@ -442,6 +445,9 @@ function drawStationMarks( scene: RouteEditScene, line: ReadonlyArray, ): void { + // 라벨 글자 상자는 그릴 때마다 새로 적는다 — 누름 판정(`stationAtScreen`)이 이것을 본다. + const labels: StationLabelBox[] = []; + setStationLabelBoxes(context.canvas, labels); if (line.length < 2) return; // 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게. drawStationTicks( @@ -452,6 +458,7 @@ function drawStationMarks( pxPerMeter: scene.pxPerMeter, toScreen: (x, y) => scene.toScreen([x, y]), uprightRad: scene.uprightRad, + onLabel: (box) => labels.push(box), }, ); const total = polylineLengthM(line); diff --git a/resources/tester/helper_b05_routeedit_pick.cjs b/resources/tester/helper_b05_routeedit_pick.cjs new file mode 100644 index 00000000..0f9353af --- /dev/null +++ b/resources/tester/helper_b05_routeedit_pick.cjs @@ -0,0 +1,161 @@ +/* 계획노선 편집 창 **측점 라벨 · 호 고르기**(계획서 22-2 · 22-3) 단위검증 헬퍼 — + * `_RouteEdit_Input.ts` · `_RouteEdit_Curve.ts` · B04 `drawStationTicks` 를 트랜스파일해 Node 로 돌린다. + * 브라우저 전용 팔레트·표기 함수는 가짜로 채운다 — 셈만 본다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const STUBS = { + "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback }, + "@util/common_util_svg": { + stationLabel: (chainage, interval) => `No.${Math.round(chainage / interval)}`, + }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const input = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Input.ts")); +const curve = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Curve.ts")); +const overlays = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapOverlays.ts")); + +// 화면 = 사업지 m × 2, y 뒤집음(실제 `metricToScreen` 처럼 y 가 화면에서 뒤집힌다). +const toScreen = ([x, y]) => [x * 2, 400 - y * 2]; +const near = (a, b, tol) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= tol; + +// 노드 세 개 · 가운데 R=30 곡선 하나. +const nodes = [ + [0, 0], + [100, 0], + [100, 100], +]; +const built = curve.buildEditedPolyline(nodes, [true, true, true], [null, 30, null], 12); +const c = built.curves[0]; + +// ① 호 점열 == 그려지는 선에서 시작~끝점 사이 조각(거울). +const drawn = built.vertices; +const iStart = drawn.findIndex((v) => near(v, c.start, 1e-9)); +const iEnd = drawn.findIndex((v) => near(v, c.end, 1e-9)); +const arc = curve.curveArcVertices(c); +const arcMatchesDrawn = + arc.length === iEnd - iStart + 1 && arc.every((v, i) => near(v, drawn[iStart + i], 1e-9)); + +// ② 호 누름 — 호 가운데 · 호에서 5px 비킨 곳(잡힘) · 곧은 선 · 빈 곳. +const mid = drawn[Math.floor((iStart + iEnd) / 2)]; +const [mx, my] = toScreen(mid); +const arcAt = (px, py) => input.arcAtScreen(built.curves, toScreen, px, py); +const pick = { + onArc: arcAt(mx, my), + nearArc: arcAt(mx + 3, my + 3), + farFromArc: arcAt(mx - 60, my + 60), + onStraight: arcAt(...toScreen([50, 0])), + empty: arcAt(5, 5), + noCurves: input.arcAtScreen([], toScreen, mx, my), +}; + +// ③ 반대로 도는(오른쪽 꺾임) 곡선도 같은 판정. +const built2 = curve.buildEditedPolyline( + [ + [0, 0], + [100, 0], + [100, -100], + ], + [true, true, true], + [null, 30, null], + 12, +); +const mid2 = built2.vertices[Math.floor(built2.vertices.length / 2)]; +const pick2 = input.arcAtScreen(built2.curves, toScreen, ...toScreen(mid2)); + +// ④ 측점 라벨 — 가짜 캔버스로 `drawStationTicks` 를 돌려 상자를 받고, 그린 사각형과 같은지 본다. +const rects = []; +const fakeContext = { + save() {}, + restore() {}, + beginPath() {}, + moveTo() {}, + lineTo() {}, + stroke() {}, + translate() {}, + rotate() {}, + fillText() {}, + fillRect: (x, y, w, h) => rects.push([x, y, w, h]), + measureText: (text) => ({ width: text.length * 6 }), +}; +const line = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, +]; +const boxes = []; +overlays.drawStationTicks(fakeContext, line, { + intervalM: 20, + pxPerMeter: 4, // 간격 80px → 두 칸에 하나(step 2) + toScreen: (x, y) => toScreen([x, y]), + uprightRad: 0.5, + onLabel: (box) => boxes.push(box), +}); +const labelsMatchRects = + boxes.length === rects.length && + boxes.every( + (box, i) => + Math.abs(rects[i][0] - (box.x - box.half)) < 1e-9 && + Math.abs(rects[i][1] - (box.y - 8)) < 1e-9 && + Math.abs(rects[i][2] - box.half * 2) < 1e-9, + ); + +// ⑤ 라벨 누름 — 라벨 가운데 · 라벨 밖 · 돌린 상자 모서리 안팎 · 눈금 우선. +const target = boxes[1]; +const stationLine = [ + [0, 0], + [100, 0], +]; +const stationAt = (px, py, labels) => + input.stationAtScreen(stationLine, toScreen, 20, px, py, 11, labels); +const rot = (dx, dy, rad) => [ + dx * Math.cos(rad) - dy * Math.sin(rad), + dx * Math.sin(rad) + dy * Math.cos(rad), +]; +// 돌린 상자의 오른쪽 위 모서리 안쪽(-1px) · 바깥쪽(+3px) — 세워 본 좌표(half, 8)를 상자 각만큼 돌린 자리. +const insideCorner = rot(target.half - 1, 7, target.uprightRad); +const outsideCorner = rot(target.half + 3, 11, target.uprightRad); +const tickScreen = toScreen([40, 0]); // 라벨이 그려진 측점(0 · 40 · 80)의 눈금 +const hiddenTickScreen = toScreen([20, 0]); // 솎여 라벨이 없는 측점의 눈금 +const label = { + count: boxes.length, + chainages: boxes.map((b) => b.chainage), + onLabel: stationAt(target.x, target.y, boxes), + insideCorner: stationAt(target.x + insideCorner[0], target.y + insideCorner[1], boxes), + outsideCorner: stationAt(target.x + outsideCorner[0], target.y + outsideCorner[1], boxes), + // 눈금 자리를 누르면 다른 측점 라벨 상자와 겹쳐도 눈금 몫이다. + tickBeatsLabel: stationAt(tickScreen[0], tickScreen[1], [ + target, + { chainage: 80, x: tickScreen[0], y: tickScreen[1], half: 30, uprightRad: 0 }, + ]), + // 안 보이는(솎인) 눈금은 누름을 가로채지 않는다 — 라벨 정보가 없을 때만 전부 본다. + hiddenTick: stationAt(hiddenTickScreen[0], hiddenTickScreen[1], boxes), + hiddenTickNoInfo: stationAt(hiddenTickScreen[0], hiddenTickScreen[1], []), + emptyNoLabels: stationAt(target.x, target.y, []), + emptyElsewhere: stationAt(5, 5, boxes), +}; + +process.stdout.write( + JSON.stringify({ arcMatchesDrawn, pick, pick2, node: c.node_first, labelsMatchRects, label }), +); diff --git a/resources/tester/test_b05_routeedit_pick.py b/resources/tester/test_b05_routeedit_pick.py new file mode 100644 index 00000000..e6bf32b0 --- /dev/null +++ b/resources/tester/test_b05_routeedit_pick.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""계획노선 편집 창 — **측점 라벨 고르기**(계획서 22-2) · **호 고르기**(22-3) 회귀검증. + +Node 헬퍼(`helper_b05_routeedit_pick.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_routeedit_pick.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_호_점열은_그려지는_선의_시작_끝_조각과_같다(results): + assert results["arcMatchesDrawn"] is True + + +def test_호를_누르면_그_곡선_노드_번호(results): + pick = results["pick"] + assert pick["onArc"] == results["node"] + assert pick["nearArc"] == results["node"] + assert results["pick2"] == results["node"] # 반대로 도는 곡선도 + + +def test_곧은_선_빈_곳_곡선_없음은_안_고름(results): + pick = results["pick"] + assert pick["farFromArc"] == -1 + assert pick["onStraight"] == -1 + assert pick["empty"] == -1 + assert pick["noCurves"] == -1 + + +def test_라벨_상자는_실제로_그린_사각형과_같다(results): + assert results["labelsMatchRects"] is True + assert results["label"]["chainages"] == [0, 40, 80] + + +def test_라벨_글자를_누르면_그_측점(results): + label = results["label"] + assert label["onLabel"] == 40 + assert label["insideCorner"] == 40 # 돌린 상자 모서리 안쪽 + assert label["outsideCorner"] is None # 모서리 바깥 + + +def test_눈금이_라벨보다_먼저(results): + assert results["label"]["tickBeatsLabel"] == 40 + + +def test_솎여_안_보이는_눈금은_안_잡힘(results): + assert results["label"]["hiddenTick"] is None + assert results["label"]["hiddenTickNoInfo"] == 20 # 라벨 정보가 없으면 예전처럼 전부 + + +def test_라벨이_없거나_빈_곳은_안_고름(results): + label = results["label"] + assert label["emptyNoLabels"] is None + assert label["emptyElsewhere"] is None