fix(foundry): make map objects true layer toggles

This commit is contained in:
Codex 2026-07-22 10:33:58 +03:00
parent ca26702047
commit b8fc4c5ba1
4 changed files with 111 additions and 24 deletions

View File

@ -268,6 +268,10 @@ function initialSubjectState(bindings: MapDataProductBinding[], saved: MapSubjec
})) as Record<string, MapSubjectState>; })) as Record<string, MapSubjectState>;
} }
function hasSubjectWindowControls(profile: MapPresentationProfile) {
return profile.facets.some((facet) => facet.counter || facet.filterable);
}
const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value)); const logarithmicControlValue = (value: number) => Math.log10(Math.max(Number.MIN_VALUE, value));
const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value)); const valueFromLogarithmicControl = (value: number) => Math.max(1, Math.round(10 ** value));
const formatMetricDistance = (value: number) => value >= 1000 const formatMetricDistance = (value: number) => value >= 1000
@ -628,9 +632,14 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
visible: true, visible: true,
filters: {}, filters: {},
window: defaultSubjectWindowState(0), window: defaultSubjectWindowState(0),
}).map((state) => {
const summary = presentationSummaries.find((candidate) => candidate.bindingId === state.bindingId);
return summary && !hasSubjectWindowControls(summary.profile)
? { ...state, window: { ...state.window, open: false } }
: state;
}), }),
}), }),
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, subjectStates]); }), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings, presentationProfiles, presentationSummaries, subjectStates]);
const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => { const updateSubjectState = (bindingId: string, update: (state: MapSubjectState) => MapSubjectState) => {
setSubjectStates((current) => { setSubjectStates((current) => {
@ -656,6 +665,10 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
}); });
}; };
const toggleSubjectVisibility = (bindingId: string) => {
updateSubjectState(bindingId, (state) => ({ ...state, visible: !state.visible }));
};
const openSubjectWindow = (bindingId: string) => { const openSubjectWindow = (bindingId: string) => {
const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1; const nextZIndex = Math.max(20, layersWindowZIndex, ...Object.values(subjectStates).map((state) => state.window.zIndex)) + 1;
updateSubjectState(bindingId, (state) => ({ updateSubjectState(bindingId, (state) => ({
@ -1201,21 +1214,38 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
{presentationSummaries.map((summary) => { {presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId]; const state = subjectStates[summary.bindingId];
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length; const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
const visible = state?.visible !== false;
const hasControls = hasSubjectWindowControls(summary.profile);
return ( return (
<button <div
type="button"
role="menuitem"
className="catalog-map-fixture__objects-menu-item" className="catalog-map-fixture__objects-menu-item"
key={summary.bindingId} key={summary.bindingId}
data-open={state?.window.open || undefined} data-visible={visible || undefined}
data-open={hasControls && state?.window.open || undefined}
>
<button
type="button"
role="menuitemcheckbox"
aria-checked={visible}
className="catalog-map-fixture__objects-menu-toggle"
onClick={() => toggleSubjectVisibility(summary.bindingId)}
>
<span>{summary.displayName}</span>
<small>{visible ? `на карте: ${visibleCount}` : `слой скрыт · ${summary.total} объектов`}</small>
</button>
{hasControls ? (
<IconButton
label={`Фильтры и счётчики: ${summary.displayName}`}
role="menuitem"
aria-pressed={state?.window.open || false}
data-active={state?.window.open || undefined}
onClick={() => { onClick={() => {
openSubjectWindow(summary.bindingId); openSubjectWindow(summary.bindingId);
close(); close();
}} }}
> ><Icon name="settings" /></IconButton>
<span>{summary.displayName}</span> ) : null}
<small>{state?.visible === false || visibleCount === 0 ? "на карте: 0" : `на карте: ${visibleCount}`}{state?.window.open ? " · окно открыто" : ""}</small> </div>
</button>
); );
})} })}
{!presentationSummaries.length ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null} {!presentationSummaries.length ? <small className="catalog-map-fixture__objects-menu-empty">Нет подключённых объектов.</small> : null}
@ -1229,7 +1259,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
{presentationSummaries.map((summary) => { {presentationSummaries.map((summary) => {
const state = subjectStates[summary.bindingId]; const state = subjectStates[summary.bindingId];
if (!state?.window.open) return null; if (!state?.window.open || !hasSubjectWindowControls(summary.profile)) return null;
const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length; const visibleCount = filteredTargets.filter((target) => target.bindingId === summary.bindingId && target.renderable).length;
return ( return (
<WorkspaceWindow <WorkspaceWindow

View File

@ -722,7 +722,7 @@ textarea {
.catalog-map-fixture__objects-menu-list, .catalog-map-fixture__objects-menu-list,
.catalog-map-fixture__objects-menu-head, .catalog-map-fixture__objects-menu-head,
.catalog-map-fixture__objects-menu-item { .catalog-map-fixture__objects-menu-toggle {
display: grid; display: grid;
} }
@ -736,7 +736,7 @@ textarea {
} }
.catalog-map-fixture__objects-menu-head small, .catalog-map-fixture__objects-menu-head small,
.catalog-map-fixture__objects-menu-item small, .catalog-map-fixture__objects-menu-toggle small,
.catalog-map-fixture__objects-menu-empty { .catalog-map-fixture__objects-menu-empty {
color: var(--nodedc-map-glass-text-muted); color: var(--nodedc-map-glass-text-muted);
font-size: var(--nodedc-font-size-xs); font-size: var(--nodedc-font-size-xs);
@ -745,24 +745,45 @@ textarea {
.catalog-map-fixture__objects-menu-item { .catalog-map-fixture__objects-menu-item {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
gap: 0.16rem; display: grid;
border: 0; grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.28rem;
border-radius: 0.78rem; border-radius: 0.78rem;
background: transparent; background: transparent;
padding: 0.64rem 0.7rem; padding: 0.18rem;
color: var(--nodedc-map-glass-text); color: var(--nodedc-map-glass-text);
}
.catalog-map-fixture__objects-menu-item:hover,
.catalog-map-fixture__objects-menu-item[data-open],
.catalog-map-fixture__objects-menu-item[data-visible] {
background: rgb(255 255 255 / 0.16);
}
.catalog-map-fixture__objects-menu-item:not([data-visible]) {
opacity: 0.62;
}
.catalog-map-fixture__objects-menu-toggle {
min-width: 0;
gap: 0.16rem;
border: 0;
border-radius: 0.64rem;
background: transparent;
padding: 0.48rem 0.52rem;
color: inherit;
font: inherit; font: inherit;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
.catalog-map-fixture__objects-menu-item:hover, .catalog-map-fixture__objects-menu-toggle:focus-visible {
.catalog-map-fixture__objects-menu-item:focus-visible, outline: 2px solid var(--nodedc-map-glass-text);
.catalog-map-fixture__objects-menu-item[data-open] { outline-offset: -2px;
background: rgb(255 255 255 / 0.16);
} }
.catalog-map-fixture__objects-menu-item > span { .catalog-map-fixture__objects-menu-toggle > span {
overflow: hidden; overflow: hidden;
font-size: var(--nodedc-font-size-sm); font-size: var(--nodedc-font-size-sm);
font-weight: 760; font-weight: 760;
@ -770,6 +791,11 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.catalog-map-fixture__objects-menu-item > .nodedc-icon-button {
width: 2.25rem;
height: 2.25rem;
}
.catalog-map-fixture__objects-menu-empty { .catalog-map-fixture__objects-menu-empty {
padding: 0.72rem; padding: 0.72rem;
} }

View File

@ -11,7 +11,7 @@
"scripts": { "scripts": {
"build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog", "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog",
"build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns",
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:hgeozone-projection", "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:hgeozone-projection && npm run test:map-object-layers",
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs", "serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs", "validate:registry": "node scripts/validate-registry.mjs",
@ -23,6 +23,7 @@
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs", "test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
"test:map-filters": "node --test scripts/map-presentation-filters.test.mjs", "test:map-filters": "node --test scripts/map-presentation-filters.test.mjs",
"test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs", "test:hgeozone-projection": "node --test scripts/hgeozone-projection.test.mjs",
"test:map-object-layers": "node --test scripts/map-object-layers.test.mjs",
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs" "test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
}, },

View File

@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("Objects menu treats each binding as a persisted visibility layer", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
assert.match(preview, /const toggleSubjectVisibility = \(bindingId: string\)/);
assert.match(preview, /role="menuitemcheckbox"/);
assert.match(preview, /aria-checked=\{visible\}/);
assert.match(preview, /onClick=\{\(\) => toggleSubjectVisibility\(summary\.bindingId\)\}/);
assert.match(preview, /visible: !state\.visible/);
assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/);
});
test("a subject window exists only when the presentation profile exposes controls", async () => {
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
assert.match(preview, /function hasSubjectWindowControls\(profile: MapPresentationProfile\)/);
assert.match(preview, /\{hasControls \? \(/);
assert.match(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/);
assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/);
});
test("hidden and visible layers have an explicit visual state", async () => {
const styles = await readFile(new URL("../apps/catalog/src/styles.css", import.meta.url), "utf8");
assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/);
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
});