feat(foundry): close the operational map data loop
This commit is contained in:
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user