feat(foundry): close the operational map data loop

This commit is contained in:
Codex
2026-07-20 20:45:05 +03:00
parent aac44d057f
commit a02c3ff3dd
44 changed files with 4158 additions and 811 deletions
+1 -4
View File
@@ -86,10 +86,7 @@ export const mapPageTemplate = {
],
contracts: {
fixtureSchema: "registry/schemas/map-scene-fixture-v0.1.schema.json",
acceptanceFixtures: [
"registry/fixtures/map/map-operational-v0.1.json",
"registry/fixtures/map/map-empty-offline-v0.1.json",
],
acceptanceFixtures: ["registry/fixtures/map/map-empty-offline-v0.1.json"],
lodPolicySchema: "registry/schemas/map-lod-policy-v0.1.schema.json",
lodPolicies: "registry/fixtures/map/map-lod-policies-v0.1.json",
},
+12
View File
@@ -31,6 +31,12 @@
--nodedc-glass-control-hover: rgba(129, 129, 129, 0.13);
--nodedc-glass-control-active: rgba(255, 255, 255, 0.92);
--nodedc-glass-control-active-text: rgba(8, 8, 10, 0.96);
/* Map overlays stay light over imagery in every application theme. */
--nodedc-map-glass-bg: rgba(255, 255, 255, 0.28);
--nodedc-map-glass-hover: rgba(255, 255, 255, 0.38);
--nodedc-map-glass-active: rgba(255, 255, 255, 0.92);
--nodedc-map-glass-text: rgba(255, 255, 255, 0.96);
--nodedc-map-glass-text-muted: rgba(255, 255, 255, 0.72);
--nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity));
--nodedc-overlay-bg: rgba(0, 0, 0, 0.46);
--nodedc-glass-rim: transparent;
@@ -96,6 +102,12 @@
--nodedc-glass-control-hover: #f8f8f8;
--nodedc-glass-control-active: rgba(32, 32, 34, 0.96);
--nodedc-glass-control-active-text: rgba(247, 248, 244, 0.96);
/* Map overlays are theme-independent because they sit on live imagery. */
--nodedc-map-glass-bg: rgba(255, 255, 255, 0.28);
--nodedc-map-glass-hover: rgba(255, 255, 255, 0.38);
--nodedc-map-glass-active: rgba(255, 255, 255, 0.92);
--nodedc-map-glass-text: rgba(255, 255, 255, 0.96);
--nodedc-map-glass-text-muted: rgba(255, 255, 255, 0.72);
--nodedc-field-bg: rgb(var(--nodedc-field-material-rgb) / var(--nodedc-field-material-opacity));
--nodedc-overlay-bg: rgba(24, 32, 29, 0.22);
--nodedc-glass-rim: transparent;
+95
View File
@@ -85,6 +85,24 @@
padding: var(--nodedc-space-6);
}
.nodedc-map-glass,
.nodedc-glass.nodedc-map-glass {
border: 0;
background: var(--nodedc-map-glass-bg);
color: var(--nodedc-map-glass-text);
box-shadow: var(--nodedc-glass-dropdown-shadow);
backdrop-filter: blur(var(--nodedc-blur-control));
-webkit-backdrop-filter: blur(var(--nodedc-blur-control));
}
.nodedc-dropdown-surface.nodedc-map-glass {
background: var(--nodedc-map-glass-bg);
color: var(--nodedc-map-glass-text);
box-shadow: var(--nodedc-glass-dropdown-shadow);
backdrop-filter: blur(var(--nodedc-blur-control));
-webkit-backdrop-filter: blur(var(--nodedc-blur-control));
}
.nodedc-button {
--nodedc-button-bg: var(--nodedc-glass-control-bg);
--nodedc-button-color: var(--nodedc-text-primary);
@@ -2112,6 +2130,83 @@ textarea.nodedc-field__control {
animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard);
}
.nodedc-toast-viewport {
position: fixed;
z-index: 1600;
right: max(1rem, env(safe-area-inset-right));
bottom: max(1rem, env(safe-area-inset-bottom));
display: grid;
width: min(24rem, calc(100vw - 2rem));
gap: 0.55rem;
pointer-events: none;
}
.nodedc-toast {
position: relative;
isolation: isolate;
display: grid;
min-height: 4.4rem;
grid-template-columns: 2.45rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.72rem;
overflow: hidden;
border-radius: var(--nodedc-radius-control);
padding: 0.72rem;
pointer-events: auto;
animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard);
}
.nodedc-toast__icon {
display: grid;
width: 2.45rem;
height: 2.45rem;
place-items: center;
border-radius: 50%;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-secondary);
box-shadow: var(--nodedc-glass-control-shadow);
}
.nodedc-toast[data-tone="success"] .nodedc-toast__icon { color: rgb(var(--nodedc-success-rgb)); }
.nodedc-toast[data-tone="error"] .nodedc-toast__icon { color: rgb(var(--nodedc-danger-rgb)); }
.nodedc-toast[data-tone="warning"] .nodedc-toast__icon { color: rgb(var(--nodedc-warning-rgb)); }
.nodedc-toast[data-tone="info"] .nodedc-toast__icon { color: var(--nodedc-accent); }
.nodedc-toast[data-tone="loading"] .nodedc-toast__icon svg { animation: nodedc-toast-spin 900ms linear infinite; }
.nodedc-toast__copy {
display: grid;
min-width: 0;
gap: 0.18rem;
}
.nodedc-toast__copy strong {
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.25;
}
.nodedc-toast__copy small {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.35;
}
.nodedc-toast__dismiss {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--nodedc-text-muted);
cursor: pointer;
}
.nodedc-toast__dismiss:hover { background: var(--nodedc-glass-control-hover); color: var(--nodedc-text-primary); }
@keyframes nodedc-toast-spin { to { transform: rotate(360deg); } }
.nodedc-window[data-size="sm"] {
width: min(28rem, calc(100vw - 2rem));
}
+4 -3
View File
@@ -52,16 +52,17 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
shape?: "circle" | "rounded";
}
export function IconButton({
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton({
label,
shape = "circle",
className,
children,
type = "button",
...props
}: IconButtonProps) {
}, ref) {
return (
<button
ref={ref}
type={type}
className={cn("nodedc-icon-button", className)}
data-shape={shape === "circle" ? undefined : shape}
@@ -72,4 +73,4 @@ export function IconButton({
{children}
</button>
);
}
});
+9
View File
@@ -43,3 +43,12 @@ export interface GlassMaterialSurfaceProps extends HTMLAttributes<HTMLDivElement
export function GlassMaterialSurface({ children, className, ...props }: GlassMaterialSurfaceProps) {
return <div className={cn("nodedc-glass-material", className)} {...props}>{children}</div>;
}
export interface MapGlassSurfaceProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
/** Light translucent surface used only over Cesium/map imagery. */
export function MapGlassSurface({ children, className, ...props }: MapGlassSurfaceProps) {
return <div className={cn("nodedc-map-glass", className)} {...props}>{children}</div>;
}
+2
View File
@@ -21,6 +21,7 @@ import {
KeyRound,
LayoutDashboard,
ListChecks,
LocateFixed,
LockKeyhole,
MailPlus,
Maximize2,
@@ -71,6 +72,7 @@ const icons = {
inbox: Inbox,
key: KeyRound,
list: ListChecks,
target: LocateFixed,
lock: LockKeyhole,
mail: MailPlus,
minimize: Minimize2,
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, type HTMLAttributes } from "react";
import { createPortal } from "react-dom";
import { cn } from "./cn.js";
import { Icon, type IconName } from "./Icon.js";
export type ToastTone = "success" | "error" | "warning" | "info" | "loading";
export type ToastItem = {
id: string;
tone: ToastTone;
title: string;
description?: string;
durationMs?: number | null;
};
const toastIcons: Record<ToastTone, IconName> = {
success: "check",
error: "alert",
warning: "alert",
info: "activity",
loading: "refresh",
};
export interface ToastCardProps extends HTMLAttributes<HTMLDivElement> {
item: ToastItem;
onDismiss?: (id: string) => void;
}
export function ToastCard({ item, onDismiss, className, ...props }: ToastCardProps) {
return (
<div
className={cn("nodedc-toast nodedc-glass-material nodedc-material-rim", className)}
data-tone={item.tone}
role={item.tone === "error" ? "alert" : "status"}
{...props}
>
<span className="nodedc-toast__icon" aria-hidden="true"><Icon name={toastIcons[item.tone]} /></span>
<span className="nodedc-toast__copy">
<strong>{item.title}</strong>
{item.description ? <small>{item.description}</small> : null}
</span>
{onDismiss ? (
<button type="button" className="nodedc-toast__dismiss" aria-label="Закрыть уведомление" onClick={() => onDismiss(item.id)}>
<Icon name="close" />
</button>
) : null}
</div>
);
}
export function ToastStack({ items, onDismiss }: { items: ToastItem[]; onDismiss: (id: string) => void }) {
useEffect(() => {
const timers = items.flatMap((item) => {
const duration = item.durationMs === undefined ? 4200 : item.durationMs;
return typeof duration === "number" && duration > 0
? [window.setTimeout(() => onDismiss(item.id), duration)]
: [];
});
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [items, onDismiss]);
if (typeof document === "undefined" || items.length === 0) return null;
return createPortal(
<div className="nodedc-toast-viewport nodedc-ui-root" aria-live="polite" aria-relevant="additions removals">
{items.map((item) => <ToastCard key={item.id} item={item} onDismiss={onDismiss} />)}
</div>,
document.body,
);
}
+1
View File
@@ -20,6 +20,7 @@ export * from "./StatusBadge.js";
export * from "./Settings.js";
export * from "./SharingModals.js";
export * from "./Toolbar.js";
export * from "./Toast.js";
export * from "./UserProfileMenu.js";
export * from "./Window.js";
export * from "./WorkspaceWindow.js";