From e8729401fd650ddf15ad9bd6e373913b5a39f6a8 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 6 Aug 2026 12:10:26 +0300 Subject: [PATCH] feat(map): refine sector inspector styling --- apps/catalog/src/CesiumMapRenderer.tsx | 123 +++++++++++++++++++++++-- apps/catalog/src/MapFixturePreview.tsx | 67 +++++++++++++- apps/catalog/src/mapGridPolicy.d.mts | 5 + apps/catalog/src/mapGridPolicy.mjs | 15 +++ apps/catalog/src/styles.css | 111 +++++++++++++++++++++- docs/FOUNDRY_MAP_CESIUM_CANON.md | 10 +- runtime-seed/page-layouts/map.json | 10 +- scripts/map-grid-lod.test.mjs | 48 +++++++++- server/catalog-server.mjs | 27 ++++++ server/foundry-mcp.mjs | 5 + server/map-grid-persistence.test.mjs | 42 +++++++++ 11 files changed, 437 insertions(+), 26 deletions(-) diff --git a/apps/catalog/src/CesiumMapRenderer.tsx b/apps/catalog/src/CesiumMapRenderer.tsx index 11c5894..22b5c06 100644 --- a/apps/catalog/src/CesiumMapRenderer.tsx +++ b/apps/catalog/src/CesiumMapRenderer.tsx @@ -316,6 +316,11 @@ export type GridLodProfile = { volumeMinimumHeightMeters: number; volumeMaximumHeightMeters: number; volumeBandHeightMeters: number; + selectionFillColor: string; + selectionFillOpacityPercent: number; + selectionOutlineColor: string; + selectionOutlineWidthPx: number; + selectionOutlineOpacityPercent: number; }; export type GridVolumeSelection = { @@ -1006,6 +1011,11 @@ type LocalGridAddressing = { volumeMinimumHeightMeters: number; volumeMaximumHeightMeters: number; volumeBandHeightMeters: number; + selectionFillColor: string; + selectionFillOpacityPercent: number; + selectionOutlineColor: string; + selectionOutlineWidthPx: number; + selectionOutlineOpacityPercent: number; }; type GraticuleGridAddressing = { mode: "graticule"; @@ -1017,6 +1027,11 @@ type GraticuleGridAddressing = { lineColor: string; lineOpacity: number; majorLineWidthMultiplier: number; + selectionFillColor: string; + selectionFillOpacityPercent: number; + selectionOutlineColor: string; + selectionOutlineWidthPx: number; + selectionOutlineOpacityPercent: number; }; type GridAddressing = LocalGridAddressing | GraticuleGridAddressing; type GridResources = { @@ -1525,7 +1540,56 @@ function materializeGridLayer(plan: Exclude, seri return plan.mode === "3d" ? materializeLocalGrid(plan, serial) : materializeGraticule(plan, serial); } -const GRID_SELECTION_COLOR = Color.fromCssColorString("#35CFFF"); +const DEFAULT_GRID_SELECTION_COLOR = "#35cfff"; + +type GridSelectionStyle = { + fillColor: Color; + outlineColor: Color; + outlineWidthPixels: number; +}; + +function safeGridSelectionColor(value: unknown) { + if (typeof value === "string" && value.trim()) { + try { + const parsed = Color.fromCssColorString(value.trim()); + if (parsed) return parsed; + } catch { + // Persisted profiles may predate strict colour validation. Rendering a + // stable selection is preferable to failing the whole Cesium layer. + } + } + return Color.fromCssColorString(DEFAULT_GRID_SELECTION_COLOR); +} + +function safeGridSelectionPercent(value: unknown, fallback: number) { + const candidate = typeof value === "number" ? value : Number.NaN; + return clamp(Number.isFinite(candidate) ? candidate : fallback, 0, 100) / 100; +} + +function resolveGridSelectionStyle(addressing: GridAddressing): GridSelectionStyle { + const fallbackOutlineWidth = addressing.mode === "3d" + ? clamp(2.5 * addressing.majorLineWidthMultiplier, 3, 12) + : clamp(addressing.lineWidthPixels * addressing.majorLineWidthMultiplier * 1.5, 3, 12); + const requestedOutlineWidth = typeof addressing.selectionOutlineWidthPx === "number" + ? addressing.selectionOutlineWidthPx + : Number.NaN; + const fallbackFillOpacityPercent = addressing.mode === "graticule" + ? 12 + : addressing.volumeEnabled ? 10 : 16; + return { + fillColor: safeGridSelectionColor(addressing.selectionFillColor).withAlpha( + safeGridSelectionPercent(addressing.selectionFillOpacityPercent, fallbackFillOpacityPercent), + ), + outlineColor: safeGridSelectionColor(addressing.selectionOutlineColor).withAlpha( + safeGridSelectionPercent(addressing.selectionOutlineOpacityPercent, 95), + ), + outlineWidthPixels: clamp( + Number.isFinite(requestedOutlineWidth) ? requestedOutlineWidth : fallbackOutlineWidth, + 1, + 12, + ), + }; +} function sampledRange(start: number, end: number, maximumStep: number) { const span = end - start; @@ -1562,6 +1626,7 @@ function materializeLocalSelection( const dataSource = new CustomDataSource(`nodedc-map-grid-selection-local:${serial}`); const crosses = new PolylineCollection(); + const selectionStyle = resolveGridSelectionStyle(addressing); const volumeAvailable = addressing.volumeEnabled && Number.isFinite(addressing.volumeBandHeightMeters) && addressing.volumeBandHeightMeters > 0 @@ -1582,9 +1647,9 @@ function materializeLocalSelection( const polyline = crosses.add({ id: { kind: "nodedc-grid-selection", sectorId: selection.id, edgeId: id }, positions, - width: clamp(2.5 * addressing.majorLineWidthMultiplier, 3, 12), + width: selectionStyle.outlineWidthPixels, }); - if (polyline.material?.uniforms) polyline.material.uniforms.color = GRID_SELECTION_COLOR.withAlpha(0.95); + if (polyline.material?.uniforms) polyline.material.uniforms.color = selectionStyle.outlineColor; }; addLine("floor", floorPerimeter); if (ceiling > floor + 1e-6) addLine("ceiling", ceilingPerimeter); @@ -1608,7 +1673,7 @@ function materializeLocalSelection( polygon: { hierarchy: new PolygonHierarchy(ceilingSurface), perPositionHeight: true, - material: GRID_SELECTION_COLOR.withAlpha(volumeAvailable ? 0.1 : 0.16), + material: selectionStyle.fillColor, outline: false, }, }); @@ -1621,6 +1686,7 @@ function materializeGraticuleSelection( serial: number, ): GridResources { const dataSource = new CustomDataSource(`nodedc-map-grid-selection-graticule:${serial}`); + const selectionStyle = resolveGridSelectionStyle(addressing); const height = Math.max(0, addressing.heightMeters); const horizontalStep = Math.min(10, Math.max(0.5, addressing.stepDegrees)); const verticalStep = Math.min(10, Math.max(0.5, addressing.stepDegrees)); @@ -1643,8 +1709,8 @@ function materializeGraticuleSelection( id: `${selection.id}/selection-surface`, polyline: { positions: perimeter, - width: clamp(addressing.lineWidthPixels * addressing.majorLineWidthMultiplier * 1.5, 3, 12), - material: GRID_SELECTION_COLOR.withAlpha(0.95), + width: selectionStyle.outlineWidthPixels, + material: selectionStyle.outlineColor, clampToGround, arcType: ArcType.RHUMB, granularity: graticuleGranularity(horizontalStep, clampToGround), @@ -1654,7 +1720,7 @@ function materializeGraticuleSelection( height: clampToGround ? undefined : height, heightReference: clampToGround ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE, arcType: ArcType.RHUMB, - material: GRID_SELECTION_COLOR.withAlpha(0.12), + material: selectionStyle.fillColor, outline: false, }, }); @@ -1712,12 +1778,38 @@ class GridLayerController { : JSON.stringify([addressing.mode, addressing.lod, addressing.stepDegrees, addressing.majorStepDegrees]); } + private syncSelectionStyle(plan: Exclude) { + let changed = false; + const targetLod = plan.lod.index + 1; + for (const layer of [this.current, this.pending]) { + const addressing = layer?.addressing; + if (!addressing || addressing.mode !== plan.mode || addressing.lod !== targetLod) continue; + if ( + addressing.selectionFillColor === plan.lod.selectionFillColor + && addressing.selectionFillOpacityPercent === plan.lod.selectionFillOpacityPercent + && addressing.selectionOutlineColor === plan.lod.selectionOutlineColor + && addressing.selectionOutlineWidthPx === plan.lod.selectionOutlineWidthPx + && addressing.selectionOutlineOpacityPercent === plan.lod.selectionOutlineOpacityPercent + ) continue; + addressing.selectionFillColor = plan.lod.selectionFillColor; + addressing.selectionFillOpacityPercent = plan.lod.selectionFillOpacityPercent; + addressing.selectionOutlineColor = plan.lod.selectionOutlineColor; + addressing.selectionOutlineWidthPx = plan.lod.selectionOutlineWidthPx; + addressing.selectionOutlineOpacityPercent = plan.lod.selectionOutlineOpacityPercent; + changed = true; + } + if (changed) this.refreshSelection(this.pending?.addressing ?? this.current?.addressing); + } + rebuild(presentation: MapPresentation) { // Planning is intentionally cheap. Geometry is materialized only after // key comparison; repeated camera/settings notifications cannot create // and discard thousands of Cesium objects for an unchanged layer. const plan = planGridLayer(this.viewer, presentation, this.lodIndex); - if (plan.key === this.key) return; + if (plan.key === this.key) { + if (plan.mode !== "hidden") this.syncSelectionStyle(plan); + return; + } this.key = plan.key; this.lodIndex = plan.lodIndex; const epoch = ++this.epoch; @@ -1751,6 +1843,11 @@ class GridLayerController { volumeMinimumHeightMeters: plan.lod.volumeMinimumHeightMeters, volumeMaximumHeightMeters: plan.lod.volumeMaximumHeightMeters, volumeBandHeightMeters: plan.lod.volumeBandHeightMeters, + selectionFillColor: plan.lod.selectionFillColor, + selectionFillOpacityPercent: plan.lod.selectionFillOpacityPercent, + selectionOutlineColor: plan.lod.selectionOutlineColor, + selectionOutlineWidthPx: plan.lod.selectionOutlineWidthPx, + selectionOutlineOpacityPercent: plan.lod.selectionOutlineOpacityPercent, } : { mode: "graticule", lod: plan.lod.index + 1, @@ -1761,6 +1858,11 @@ class GridLayerController { lineColor: plan.lod.graticuleColor, lineOpacity: plan.lod.graticuleOpacity, majorLineWidthMultiplier: plan.lod.majorLineWidthMultiplier, + selectionFillColor: plan.lod.selectionFillColor, + selectionFillOpacityPercent: plan.lod.selectionFillOpacityPercent, + selectionOutlineColor: plan.lod.selectionOutlineColor, + selectionOutlineWidthPx: plan.lod.selectionOutlineWidthPx, + selectionOutlineOpacityPercent: plan.lod.selectionOutlineOpacityPercent, }; const activeAddressing = this.pending?.addressing ?? this.current?.addressing; if (activeAddressing && this.addressingKey(activeAddressing) !== this.addressingKey(addressing)) { @@ -1943,6 +2045,11 @@ class GridLayerController { addressing.lineColor, addressing.lineOpacity, addressing.majorLineWidthMultiplier, + addressing.selectionFillColor, + addressing.selectionFillOpacityPercent, + addressing.selectionOutlineColor, + addressing.selectionOutlineWidthPx, + addressing.selectionOutlineOpacityPercent, addressing.mode === "3d" ? [ addressing.volumeEnabled, addressing.volumeMinimumHeightMeters, diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index 84812d2..fba0dec 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -75,6 +75,11 @@ type SectorGridLodProfile = GridLodProfile & { majorLinesEnabled: boolean; majorLabelsEnabled: boolean; majorLineWidthMultiplier: number; + selectionFillColor: string; + selectionFillOpacityPercent: number; + selectionOutlineColor: string; + selectionOutlineWidthPx: number; + selectionOutlineOpacityPercent: number; volumeEnabled: boolean; volumeMinimumHeightMeters: number; volumeMaximumHeightMeters: number; @@ -981,12 +986,22 @@ export const MapFixturePreview = forwardRef>) => { const minimum = patch.volumeMinimumHeightMeters ?? activeGridLod.volumeMinimumHeightMeters; - const maximum = Math.max(minimum + 1, patch.volumeMaximumHeightMeters ?? activeGridLod.volumeMaximumHeightMeters); + const requestedBandHeight = Math.min( + 1_000, + Math.max(1, patch.volumeBandHeightMeters ?? activeGridLod.volumeBandHeightMeters), + ); + let maximum = Math.min( + 10_000, + Math.max(minimum + 1, patch.volumeMaximumHeightMeters ?? activeGridLod.volumeMaximumHeightMeters), + ); + if (patch.volumeBandHeightMeters !== undefined) { + maximum = Math.min(10_000, Math.max(maximum, minimum + requestedBandHeight)); + } const span = maximum - minimum; updateGridLod({ volumeMinimumHeightMeters: minimum, volumeMaximumHeightMeters: maximum, - volumeBandHeightMeters: Math.min(span, Math.max(1, patch.volumeBandHeightMeters ?? activeGridLod.volumeBandHeightMeters)), + volumeBandHeightMeters: Math.min(span, requestedBandHeight), }); }; const setCacheEnabled = (cacheEnabled: boolean) => { @@ -1632,7 +1647,7 @@ export const MapFixturePreview = forwardRef + content:
Фиксированная московская ENU-адресация задаёт неизменные сектора на LOD 1–3. LOD 4–5 используют глобальную WGS84-гратику́лу; камера выбирает только LOD и видимую область. updateMapSettings({ gridVisible })} /> updateMapSettings({ grid3dEnabled })} /> @@ -1743,13 +1758,55 @@ export const MapFixturePreview = forwardRef `${value} м`} onChange={(volumeBandHeightMeters) => updateGridVolumeRange({ volumeBandHeightMeters })} /> Горизонтальный ID сектора остаётся стабильным. Высотный band добавляется как отдельный адрес внутри выбранной ENU-ячейки. : null} + + updateGridLod({ selectionFillColor })} + /> + + `${value}%`} + onChange={(selectionFillOpacityPercent) => updateGridLod({ selectionFillOpacityPercent })} + /> + + updateGridLod({ selectionOutlineColor })} + /> + + `${value} px`} + onChange={(selectionOutlineWidthPx) => updateGridLod({ selectionOutlineWidthPx })} + /> + `${value}%`} + onChange={(selectionOutlineOpacityPercent) => updateGridLod({ selectionOutlineOpacityPercent })} + /> + Оформление применяется к выбранному сектору текущего LOD.
{selectedGridSector?.id ?? "Нажмите сектор на карте"} {selectedGridSector ? <> @@ -1841,7 +1898,7 @@ export const MapFixturePreview = forwardRef : null} : Кликните ячейку, чтобы получить устойчивый адрес, геометрию и навигацию по соседям.}
- , +
, }, { id: "map-camera-animation", diff --git a/apps/catalog/src/mapGridPolicy.d.mts b/apps/catalog/src/mapGridPolicy.d.mts index e855793..04c0dcc 100644 --- a/apps/catalog/src/mapGridPolicy.d.mts +++ b/apps/catalog/src/mapGridPolicy.d.mts @@ -32,6 +32,11 @@ export type GridLodProfile = { volumeMinimumHeightMeters: number; volumeMaximumHeightMeters: number; volumeBandHeightMeters: number; + selectionFillColor: string; + selectionFillOpacityPercent: number; + selectionOutlineColor: string; + selectionOutlineWidthPx: number; + selectionOutlineOpacityPercent: number; }; export type GridLodBand = GridLodProfile & { index: number; diff --git a/apps/catalog/src/mapGridPolicy.mjs b/apps/catalog/src/mapGridPolicy.mjs index 225f7a0..5ace8dd 100644 --- a/apps/catalog/src/mapGridPolicy.mjs +++ b/apps/catalog/src/mapGridPolicy.mjs @@ -18,6 +18,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ graticuleStepDegrees: 0.25, graticuleLineWidthPx: 1, graticuleColor: "#f5f5f5", graticuleOpacity: 12, majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300, + selectionFillColor: "#35cfff", selectionFillOpacityPercent: 10, + selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 6.25, selectionOutlineOpacityPercent: 95, }), Object.freeze({ maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -27,6 +29,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ graticuleStepDegrees: 0.5, graticuleLineWidthPx: 1, graticuleColor: "#f8fbfc", graticuleOpacity: 12, majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, + selectionFillColor: "#35cfff", selectionFillOpacityPercent: 10, + selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 6.25, selectionOutlineOpacityPercent: 95, }), Object.freeze({ maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -36,6 +40,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ graticuleStepDegrees: 1, graticuleLineWidthPx: 1, graticuleColor: "#fafafa", graticuleOpacity: 12, majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, + selectionFillColor: "#35cfff", selectionFillOpacityPercent: 16, + selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 5, selectionOutlineOpacityPercent: 95, }), Object.freeze({ maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -45,6 +51,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#fcfdfd", graticuleOpacity: 12, majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, + selectionFillColor: "#35cfff", selectionFillOpacityPercent: 12, + selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 3, selectionOutlineOpacityPercent: 95, }), Object.freeze({ // Engine keeps the last LOD selected above this threshold; the independent @@ -56,6 +64,8 @@ export const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#9c9c9c", graticuleOpacity: 8, majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, + selectionFillColor: "#35cfff", selectionFillOpacityPercent: 12, + selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 3, selectionOutlineOpacityPercent: 95, }), ]); @@ -98,6 +108,11 @@ export function gridLodProfile(settings, index) { volumeMinimumHeightMeters: clamp(finite(source.volumeMinimumHeightMeters, fallback.volumeMinimumHeightMeters), -1_000, 10_000), volumeMaximumHeightMeters: clamp(finite(source.volumeMaximumHeightMeters, fallback.volumeMaximumHeightMeters), -1_000, 10_000), volumeBandHeightMeters: clamp(finite(source.volumeBandHeightMeters, fallback.volumeBandHeightMeters), 1, 10_000), + selectionFillColor: color(source.selectionFillColor, fallback.selectionFillColor), + selectionFillOpacityPercent: clamp(finite(source.selectionFillOpacityPercent, fallback.selectionFillOpacityPercent), 0, 100), + selectionOutlineColor: color(source.selectionOutlineColor, fallback.selectionOutlineColor), + selectionOutlineWidthPx: clamp(finite(source.selectionOutlineWidthPx, fallback.selectionOutlineWidthPx), 1, 12), + selectionOutlineOpacityPercent: clamp(finite(source.selectionOutlineOpacityPercent, fallback.selectionOutlineOpacityPercent), 0, 100), }; } diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index f3eb537..98df40c 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -958,6 +958,63 @@ textarea { white-space: nowrap; } +.catalog-map-grid-inspector { + display: grid; + width: 100%; + max-width: 100%; + min-width: 0; + gap: var(--nodedc-space-3); + font-family: var(--nodedc-font-family); + font-size: var(--nodedc-font-size-sm); +} + +.catalog-map-grid-inspector :where(*) { + min-width: 0; + max-width: 100%; + font-family: var(--nodedc-font-family); +} + +.catalog-map-grid-inspector :where( + .nodedc-checker__label, + .nodedc-range__label, + .nodedc-range__value, + .nodedc-range__editor, + .nodedc-select__value, + .nodedc-select-trigger__label, + .nodedc-control-row__label, + .nodedc-control-row__control, + .catalog-map-inspector__note, + .nodedc-segmented__item, + .nodedc-color-field__text, + small, + span, + strong, + code +), +.catalog-map-grid-inspector .nodedc-button { + font-family: var(--nodedc-font-family); + font-size: var(--nodedc-font-size-sm); +} + +.catalog-map-grid-inspector .nodedc-checker__label { + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; +} + +.catalog-map-grid-inspector :where( + .nodedc-control-row__label, + .nodedc-control-row__control, + .catalog-map-inspector__note, + small, + span, + strong, + code, + .nodedc-button +) { + overflow-wrap: anywhere; +} + .catalog-map-grid-lod-tabs { width: 100%; min-width: 0; @@ -973,22 +1030,70 @@ textarea { flex: 1 1 0; min-width: 0; padding-inline: 0.45rem; - font-size: var(--nodedc-font-size-xs); + font-size: var(--nodedc-font-size-sm); white-space: nowrap; } .catalog-map-grid-sector-id { + display: block; + width: 100%; min-width: 0; overflow-wrap: anywhere; text-align: right; + white-space: normal; } .catalog-map-grid-sector { display: grid; + width: 100%; + max-width: 100%; min-width: 0; gap: 0.55rem; border-top: 1px solid color-mix(in srgb, var(--nodedc-glass-outline) 44%, transparent); padding-top: 0.7rem; + font-family: var(--nodedc-font-family); + font-size: var(--nodedc-font-size-sm); + line-height: 1.35; +} + +.catalog-map-grid-sector :where(*) { + min-width: 0; + max-width: 100%; + font-family: var(--nodedc-font-family); +} + +.catalog-map-grid-sector :where( + .nodedc-control-row__label, + .nodedc-control-row__control, + small, + span, + strong, + code +), +.catalog-map-grid-sector .nodedc-button { + font-size: var(--nodedc-font-size-sm); + line-height: 1.35; +} + +.catalog-map-grid-sector .nodedc-control-row { + width: 100%; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + align-items: start; + gap: 0.5rem; +} + +.catalog-map-grid-sector :where( + .nodedc-control-row__label, + .nodedc-control-row__control, + small, + span, + strong, + code, + .nodedc-button +) { + overflow-wrap: anywhere; + word-break: normal; + white-space: normal; } .catalog-map-grid-sector__facts, @@ -1009,7 +1114,10 @@ textarea { .catalog-map-grid-sector__neighbor, .catalog-map-grid-sector__volume { display: grid; + width: 100%; + max-width: 100%; min-width: 0; + overflow: hidden; gap: 0.4rem; border-radius: var(--nodedc-radius-control); background: var(--nodedc-glass-control-bg); @@ -1020,7 +1128,6 @@ textarea { .catalog-map-grid-sector__neighbor > code, .catalog-map-grid-sector__volume > code { color: var(--nodedc-text-muted); - font-size: var(--nodedc-font-size-xs); line-height: 1.35; } diff --git a/docs/FOUNDRY_MAP_CESIUM_CANON.md b/docs/FOUNDRY_MAP_CESIUM_CANON.md index e409e7c..0fe47c2 100644 --- a/docs/FOUNDRY_MAP_CESIUM_CANON.md +++ b/docs/FOUNDRY_MAP_CESIUM_CANON.md @@ -195,10 +195,18 @@ Per-entity `PolygonGraphics` для массового слоя геозон н - LOD 1–2 по умолчанию поддерживают отдельный высотный адрес ENU-объёма. Диапазон задаётся как полуоткрытый `[minimum, maximum)`, делится на bands, а последний band может быть короче. Volume id является дочерним адресом и не - подменяет стабильный id горизонтального сектора; + подменяет стабильный id горизонтального сектора. Inspector позволяет задать + band до `1000 м`; если он превышает текущий span, верхняя отметка объёма + расширяется вместе с ним, поэтому persisted policy всегда сохраняет + инвариант `band <= maximum - minimum`; - Cesium материализует пространственную клетку только для выбранного сектора: нижнюю и верхнюю рамки, вертикальные рёбра и границы bands. Вся ENU-плоскость не размножается в объёмные entities, поэтому стоимость выбора ограничена; +- заливка и outline выбранного сектора являются независимыми настройками + каждого LOD: `selectionFillColor`, `selectionFillOpacityPercent`, + `selectionOutlineColor`, `selectionOutlineWidthPx` и + `selectionOutlineOpacityPercent`. Legacy layouts получают визуально + эквивалентные cyan-defaults при миграции; - minor и major линии собираются раздельно. Major получает только множитель толщины, наследует цвет/прозрачность текущего LOD, а подписи создаются ограниченным набором вокруг viewport и не являются источником адресации; diff --git a/runtime-seed/page-layouts/map.json b/runtime-seed/page-layouts/map.json index b5410ae..5270d68 100644 --- a/runtime-seed/page-layouts/map.json +++ b/runtime-seed/page-layouts/map.json @@ -75,11 +75,11 @@ "gridCrossesColor": "#9c9c9c", "gridCrossesOpacity": 46, "gridLodProfiles": [ - { "maxHeightKm": 10, "stepKm": 1, "mode": "3d", "heightMeters": 300, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 50, "lineDiameterMeters": 4, "lineColor": "#f5f5f5", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 7, "dotsColor": "#ffffff", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 0.25, "graticuleLineWidthPx": 1, "graticuleColor": "#f5f5f5", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": true, "majorLineWidthMultiplier": 2.5, "volumeEnabled": true, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 300, "volumeBandHeightMeters": 300 }, - { "maxHeightKm": 50, "stepKm": 5, "mode": "3d", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 1000, "lineDiameterMeters": 10, "lineColor": "#f8fbfc", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 80, "dotsColor": "#ffffff", "dotsOpacity": 22, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 0.5, "graticuleLineWidthPx": 1, "graticuleColor": "#f8fbfc", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": true, "majorLineWidthMultiplier": 2.5, "volumeEnabled": true, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 }, - { "maxHeightKm": 200, "stepKm": 25, "mode": "3d", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 25, "radiusKm": 1000, "lineDiameterMeters": 7, "lineColor": "#fafafa", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#ffffff", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 1, "graticuleLineWidthPx": 1, "graticuleColor": "#fafafa", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 }, - { "maxHeightKm": 800, "stepKm": 50, "mode": "graticule", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 1000, "lineDiameterMeters": 10, "lineColor": "#fcfdfd", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#fcfdfd", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 2, "graticuleLineWidthPx": 1, "graticuleColor": "#fcfdfd", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 }, - { "maxHeightKm": 3000, "stepKm": 50, "mode": "graticule", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 100, "lineDiameterMeters": 7, "lineColor": "#9c9c9c", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#9c9c9c", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 2, "graticuleLineWidthPx": 1, "graticuleColor": "#9c9c9c", "graticuleOpacity": 8, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500 } + { "maxHeightKm": 10, "stepKm": 1, "mode": "3d", "heightMeters": 300, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 50, "lineDiameterMeters": 4, "lineColor": "#f5f5f5", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 7, "dotsColor": "#ffffff", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 0.25, "graticuleLineWidthPx": 1, "graticuleColor": "#f5f5f5", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": true, "majorLineWidthMultiplier": 2.5, "volumeEnabled": true, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 300, "volumeBandHeightMeters": 300, "selectionFillColor": "#35cfff", "selectionFillOpacityPercent": 10, "selectionOutlineColor": "#35cfff", "selectionOutlineWidthPx": 6.25, "selectionOutlineOpacityPercent": 95 }, + { "maxHeightKm": 50, "stepKm": 5, "mode": "3d", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 1000, "lineDiameterMeters": 10, "lineColor": "#f8fbfc", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 80, "dotsColor": "#ffffff", "dotsOpacity": 22, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 0.5, "graticuleLineWidthPx": 1, "graticuleColor": "#f8fbfc", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": true, "majorLineWidthMultiplier": 2.5, "volumeEnabled": true, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500, "selectionFillColor": "#35cfff", "selectionFillOpacityPercent": 10, "selectionOutlineColor": "#35cfff", "selectionOutlineWidthPx": 6.25, "selectionOutlineOpacityPercent": 95 }, + { "maxHeightKm": 200, "stepKm": 25, "mode": "3d", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 25, "radiusKm": 1000, "lineDiameterMeters": 7, "lineColor": "#fafafa", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#ffffff", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 1, "graticuleLineWidthPx": 1, "graticuleColor": "#fafafa", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500, "selectionFillColor": "#35cfff", "selectionFillOpacityPercent": 16, "selectionOutlineColor": "#35cfff", "selectionOutlineWidthPx": 5, "selectionOutlineOpacityPercent": 95 }, + { "maxHeightKm": 800, "stepKm": 50, "mode": "graticule", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 1000, "lineDiameterMeters": 10, "lineColor": "#fcfdfd", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#fcfdfd", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 2, "graticuleLineWidthPx": 1, "graticuleColor": "#fcfdfd", "graticuleOpacity": 12, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500, "selectionFillColor": "#35cfff", "selectionFillOpacityPercent": 12, "selectionOutlineColor": "#35cfff", "selectionOutlineWidthPx": 3, "selectionOutlineOpacityPercent": 95 }, + { "maxHeightKm": 3000, "stepKm": 50, "mode": "graticule", "heightMeters": 500, "max3dViewAngleDegrees": 30, "tileSizeKm": 10, "radiusKm": 100, "lineDiameterMeters": 7, "lineColor": "#9c9c9c", "lineOpacity": 12, "dotsEnabled": true, "dotsDiameterMeters": 10, "dotsColor": "#9c9c9c", "dotsOpacity": 58, "crossesEnabled": false, "crossesLengthMeters": 60, "crossesWidthMeters": 10, "crossesColor": "#9c9c9c", "crossesOpacity": 46, "graticuleStepDegrees": 2, "graticuleLineWidthPx": 1, "graticuleColor": "#9c9c9c", "graticuleOpacity": 8, "majorLinesEnabled": true, "majorLabelsEnabled": false, "majorLineWidthMultiplier": 2, "volumeEnabled": false, "volumeMinimumHeightMeters": 0, "volumeMaximumHeightMeters": 500, "volumeBandHeightMeters": 500, "selectionFillColor": "#35cfff", "selectionFillOpacityPercent": 12, "selectionOutlineColor": "#35cfff", "selectionOutlineWidthPx": 3, "selectionOutlineOpacityPercent": 95 } ] }, "mapHeight": 620, diff --git a/scripts/map-grid-lod.test.mjs b/scripts/map-grid-lod.test.mjs index 0390ff1..1a250bf 100644 --- a/scripts/map-grid-lod.test.mjs +++ b/scripts/map-grid-lod.test.mjs @@ -31,6 +31,11 @@ test("canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD do volumeMinimumHeightMeters: _volumeMinimumHeightMeters, volumeMaximumHeightMeters: _volumeMaximumHeightMeters, volumeBandHeightMeters: _volumeBandHeightMeters, + selectionFillColor: _selectionFillColor, + selectionFillOpacityPercent: _selectionFillOpacityPercent, + selectionOutlineColor: _selectionOutlineColor, + selectionOutlineWidthPx: _selectionOutlineWidthPx, + selectionOutlineOpacityPercent: _selectionOutlineOpacityPercent, ...profile }) => profile); assert.deepEqual(donorProfiles, [ @@ -168,12 +173,17 @@ test("canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD do volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters, volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters, volumeBandHeightMeters: profile.volumeBandHeightMeters, + selectionFillColor: profile.selectionFillColor, + selectionFillOpacityPercent: profile.selectionFillOpacityPercent, + selectionOutlineColor: profile.selectionOutlineColor, + selectionOutlineWidthPx: profile.selectionOutlineWidthPx, + selectionOutlineOpacityPercent: profile.selectionOutlineOpacityPercent, })), [ - { majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300 }, - { majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 }, - { majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 }, - { majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 }, - { majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500 }, + { majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300, selectionFillColor: "#35cfff", selectionFillOpacityPercent: 10, selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 6.25, selectionOutlineOpacityPercent: 95 }, + { majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, selectionFillColor: "#35cfff", selectionFillOpacityPercent: 10, selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 6.25, selectionOutlineOpacityPercent: 95 }, + { majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, selectionFillColor: "#35cfff", selectionFillOpacityPercent: 16, selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 5, selectionOutlineOpacityPercent: 95 }, + { majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, selectionFillColor: "#35cfff", selectionFillOpacityPercent: 12, selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 3, selectionOutlineOpacityPercent: 95 }, + { majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, selectionFillColor: "#35cfff", selectionFillOpacityPercent: 12, selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 3, selectionOutlineOpacityPercent: 95 }, ]); }); @@ -210,6 +220,11 @@ test("every LOD owns independent metric and angular spacing and visual fields", graticuleLineWidthPx: 1 + (index % 3), graticuleColor: `#00000${index}`, graticuleOpacity: 30 + index, + selectionFillColor: `#12345${index}`, + selectionFillOpacityPercent: 40 + index, + selectionOutlineColor: `#abcde${index}`, + selectionOutlineWidthPx: 4 + index, + selectionOutlineOpacityPercent: 70 + index, })); const profile = gridLodProfile({ ...settings, gridLodProfiles: profiles }, 3); assert.equal(profile.stepKm, 14); @@ -218,6 +233,29 @@ test("every LOD owns independent metric and angular spacing and visual fields", assert.equal(profile.graticuleLineWidthPx, 1); assert.equal(profile.graticuleColor, "#000003"); assert.equal(profile.graticuleOpacity, 33); + assert.equal(profile.selectionFillColor, "#123453"); + assert.equal(profile.selectionFillOpacityPercent, 43); + assert.equal(profile.selectionOutlineColor, "#abcde3"); + assert.equal(profile.selectionOutlineWidthPx, 7); + assert.equal(profile.selectionOutlineOpacityPercent, 73); +}); + +test("selection visuals clamp to their public UI contract and a 1,000 m band is accepted client-side", () => { + const profile = gridLodProfile({ + ...settings, + gridLodProfiles: DEFAULT_GRID_LOD_PROFILES.map((item, index) => index === 0 ? { + ...item, + volumeMaximumHeightMeters: 1_000, + volumeBandHeightMeters: 1_000, + selectionFillOpacityPercent: 110, + selectionOutlineWidthPx: 30, + selectionOutlineOpacityPercent: -5, + } : item), + }, 0); + assert.equal(profile.volumeBandHeightMeters, 1_000); + assert.equal(profile.selectionFillOpacityPercent, 100); + assert.equal(profile.selectionOutlineWidthPx, 12); + assert.equal(profile.selectionOutlineOpacityPercent, 0); }); test("legacy flat visuals migrate while angular graticule spacing stays independent", () => { diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs index a2b0561..7d46b9c 100644 --- a/server/catalog-server.mjs +++ b/server/catalog-server.mjs @@ -538,6 +538,8 @@ const GRID_LOD_PROFILE_KEYS = new Set([ "crossesColor", "crossesOpacity", "graticuleStepDegrees", "graticuleLineWidthPx", "graticuleColor", "graticuleOpacity", "majorLinesEnabled", "majorLabelsEnabled", "majorLineWidthMultiplier", "volumeEnabled", "volumeMinimumHeightMeters", "volumeMaximumHeightMeters", "volumeBandHeightMeters", + "selectionFillColor", "selectionFillOpacityPercent", "selectionOutlineColor", "selectionOutlineWidthPx", + "selectionOutlineOpacityPercent", ]); const GRID_LOD_PROFILE_INPUT_KEYS = new Set([...GRID_LOD_PROFILE_KEYS, "lineWidthPx"]); @@ -564,6 +566,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ graticuleStepDegrees: 0.25, graticuleLineWidthPx: 1, graticuleColor: "#f5f5f5", graticuleOpacity: 12, majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 300, volumeBandHeightMeters: 300, + selectionFillColor: "#35cfff", selectionFillOpacityPercent: 10, + selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 6.25, selectionOutlineOpacityPercent: 95, }), Object.freeze({ maxHeightKm: 50, stepKm: 5, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -573,6 +577,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ graticuleStepDegrees: 0.5, graticuleLineWidthPx: 1, graticuleColor: "#f8fbfc", graticuleOpacity: 12, majorLinesEnabled: true, majorLabelsEnabled: true, majorLineWidthMultiplier: 2.5, volumeEnabled: true, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, + selectionFillColor: "#35cfff", selectionFillOpacityPercent: 10, + selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 6.25, selectionOutlineOpacityPercent: 95, }), Object.freeze({ maxHeightKm: 200, stepKm: 25, mode: "3d", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -582,6 +588,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ graticuleStepDegrees: 1, graticuleLineWidthPx: 1, graticuleColor: "#fafafa", graticuleOpacity: 12, majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, + selectionFillColor: "#35cfff", selectionFillOpacityPercent: 16, + selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 5, selectionOutlineOpacityPercent: 95, }), Object.freeze({ maxHeightKm: 800, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -591,6 +599,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#fcfdfd", graticuleOpacity: 12, majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, + selectionFillColor: "#35cfff", selectionFillOpacityPercent: 12, + selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 3, selectionOutlineOpacityPercent: 95, }), Object.freeze({ maxHeightKm: 3_000, stepKm: 50, mode: "graticule", heightMeters: 500, max3dViewAngleDegrees: 30, @@ -600,6 +610,8 @@ const DEFAULT_GRID_LOD_PROFILES = Object.freeze([ graticuleStepDegrees: 2, graticuleLineWidthPx: 1, graticuleColor: "#9c9c9c", graticuleOpacity: 8, majorLinesEnabled: true, majorLabelsEnabled: false, majorLineWidthMultiplier: 2, volumeEnabled: false, volumeMinimumHeightMeters: 0, volumeMaximumHeightMeters: 500, volumeBandHeightMeters: 500, + selectionFillColor: "#35cfff", selectionFillOpacityPercent: 12, + selectionOutlineColor: "#35cfff", selectionOutlineWidthPx: 3, selectionOutlineOpacityPercent: 95, }), ]); @@ -618,6 +630,11 @@ function promoteLegacyGridLodProfiles(settings) { volumeMinimumHeightMeters: _volumeMinimumHeightMeters, volumeMaximumHeightMeters: _volumeMaximumHeightMeters, volumeBandHeightMeters: _volumeBandHeightMeters, + selectionFillColor: _selectionFillColor, + selectionFillOpacityPercent: _selectionFillOpacityPercent, + selectionOutlineColor: _selectionOutlineColor, + selectionOutlineWidthPx: _selectionOutlineWidthPx, + selectionOutlineOpacityPercent: _selectionOutlineOpacityPercent, ...legacyFallback } = fallback; return { @@ -679,6 +696,11 @@ function validateGridLodProfiles(value) { volumeMinimumHeightMeters: profile.volumeMinimumHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeMinimumHeightMeters, volumeMaximumHeightMeters: profile.volumeMaximumHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeMaximumHeightMeters, volumeBandHeightMeters: profile.volumeBandHeightMeters ?? DEFAULT_GRID_LOD_PROFILES[index].volumeBandHeightMeters, + selectionFillColor: profile.selectionFillColor ?? DEFAULT_GRID_LOD_PROFILES[index].selectionFillColor, + selectionFillOpacityPercent: profile.selectionFillOpacityPercent ?? DEFAULT_GRID_LOD_PROFILES[index].selectionFillOpacityPercent, + selectionOutlineColor: profile.selectionOutlineColor ?? DEFAULT_GRID_LOD_PROFILES[index].selectionOutlineColor, + selectionOutlineWidthPx: profile.selectionOutlineWidthPx ?? DEFAULT_GRID_LOD_PROFILES[index].selectionOutlineWidthPx, + selectionOutlineOpacityPercent: profile.selectionOutlineOpacityPercent ?? DEFAULT_GRID_LOD_PROFILES[index].selectionOutlineOpacityPercent, }; delete normalized.lineWidthPx; if (GRID_LOD_PROFILE_KEYS.size !== Object.keys(normalized).length @@ -771,6 +793,11 @@ function validateGridLodProfiles(value) { volumeMinimumHeightMeters, volumeMaximumHeightMeters, volumeBandHeightMeters, + selectionFillColor: requireHex(normalized.selectionFillColor, code("selectionFillColor")), + selectionFillOpacityPercent: requireNumber(normalized.selectionFillOpacityPercent, 0, 100, code("selectionFillOpacityPercent")), + selectionOutlineColor: requireHex(normalized.selectionOutlineColor, code("selectionOutlineColor")), + selectionOutlineWidthPx: requireNumber(normalized.selectionOutlineWidthPx, 1, 12, code("selectionOutlineWidthPx")), + selectionOutlineOpacityPercent: requireNumber(normalized.selectionOutlineOpacityPercent, 0, 100, code("selectionOutlineOpacityPercent")), }; }); } diff --git a/server/foundry-mcp.mjs b/server/foundry-mcp.mjs index 5d73ca0..81ded82 100644 --- a/server/foundry-mcp.mjs +++ b/server/foundry-mcp.mjs @@ -492,6 +492,11 @@ const mapPageSettingsPatchInputSchema = { volumeMinimumHeightMeters: { type: "number", minimum: -1000, maximum: 10000 }, volumeMaximumHeightMeters: { type: "number", minimum: -1000, maximum: 10000 }, volumeBandHeightMeters: { type: "number", minimum: 1, maximum: 10000 }, + selectionFillColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, + selectionFillOpacityPercent: { type: "number", minimum: 0, maximum: 100 }, + selectionOutlineColor: { type: "string", pattern: "^#[0-9A-Fa-f]{6}$" }, + selectionOutlineWidthPx: { type: "number", minimum: 1, maximum: 12 }, + selectionOutlineOpacityPercent: { type: "number", minimum: 0, maximum: 100 }, }, }, }, diff --git a/server/map-grid-persistence.test.mjs b/server/map-grid-persistence.test.mjs index d3d58fa..5ad8ffb 100644 --- a/server/map-grid-persistence.test.mjs +++ b/server/map-grid-persistence.test.mjs @@ -15,6 +15,11 @@ const extensionKeys = [ "volumeMinimumHeightMeters", "volumeMaximumHeightMeters", "volumeBandHeightMeters", + "selectionFillColor", + "selectionFillOpacityPercent", + "selectionOutlineColor", + "selectionOutlineWidthPx", + "selectionOutlineOpacityPercent", ]; function applicationError(code, statusCode = 400) { @@ -96,3 +101,40 @@ test("flat layouts migrate mode-aware extensions while explicit invalid hierarch test("Foundry MCP exposes every persisted sector-v2 profile field", () => { for (const key of extensionKeys) assert.match(mcpSource, new RegExp(`\\b${key}: \\{`)); }); + +test("server persists selected-sector visuals and accepts a 1,000 m address band inside its range", () => { + const profiles = structuredClone(DEFAULT_GRID_LOD_PROFILES); + Object.assign(profiles[0], { + volumeMaximumHeightMeters: 1_000, + volumeBandHeightMeters: 1_000, + selectionFillColor: "#A0B1C2", + selectionFillOpacityPercent: 34, + selectionOutlineColor: "#C3D4E5", + selectionOutlineWidthPx: 7.5, + selectionOutlineOpacityPercent: 81, + }); + const validated = structuredClone(serverGrid.validateGridLodProfiles(profiles)); + assert.equal(validated[0].volumeBandHeightMeters, 1_000); + assert.equal(validated[0].selectionFillColor, "#a0b1c2"); + assert.equal(validated[0].selectionFillOpacityPercent, 34); + assert.equal(validated[0].selectionOutlineColor, "#c3d4e5"); + assert.equal(validated[0].selectionOutlineWidthPx, 7.5); + assert.equal(validated[0].selectionOutlineOpacityPercent, 81); +}); + +test("server rejects selected-sector visual values outside the canonical bounds", () => { + for (const [field, value] of [ + ["selectionFillColor", "cyan"], + ["selectionFillOpacityPercent", 101], + ["selectionOutlineColor", "#ffff"], + ["selectionOutlineWidthPx", 12.01], + ["selectionOutlineOpacityPercent", -1], + ]) { + const profiles = structuredClone(DEFAULT_GRID_LOD_PROFILES); + profiles[0][field] = value; + assert.throws( + () => serverGrid.validateGridLodProfiles(profiles), + new RegExp(`invalid_map_grid_lod_profile_1_${field}`), + ); + } +});