feat(map): add reference layers and stabilize workspace controls
This commit is contained in:
@@ -8,7 +8,7 @@ export type PageTemplateFeature = {
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type PageTemplateSlotKind = "entity-stream" | "trace-stream" | "route-stream" | "zone-stream" | "selection" | "command" | "assistant";
|
||||
export type PageTemplateSlotKind = "entity-stream" | "trace-stream" | "route-stream" | "zone-stream" | "subject-aspect-stream" | "selection" | "command" | "assistant";
|
||||
|
||||
export type PageTemplateSlot = {
|
||||
id: string;
|
||||
@@ -75,6 +75,8 @@ export const mapPageTemplate = {
|
||||
{ id: "traces", kind: "trace-stream", label: "Traces", description: "Time-ordered entity traces.", required: false, multiple: true },
|
||||
{ id: "routes", kind: "route-stream", label: "Routes", description: "Planned or computed routes.", required: false, multiple: true },
|
||||
{ id: "zones", kind: "zone-stream", label: "Zones", description: "Polygons, geofences and operational areas.", required: false, multiple: true },
|
||||
{ id: "reference-points", kind: "entity-stream", label: "Reference points", description: "Platform-owned provider-neutral reference entities such as stations and terminals.", required: false, multiple: true },
|
||||
{ id: "subject-details", kind: "subject-aspect-stream", label: "Subject details", description: "Provider-neutral current aspects joined to a stable selected subject.", required: false, multiple: true },
|
||||
{ id: "selection", kind: "selection", label: "Selection", description: "Selected map entity and contextual state.", required: false, multiple: false },
|
||||
{ id: "commands", kind: "command", label: "Commands", description: "Capability-backed actions for selected entities.", required: false, multiple: true },
|
||||
{ id: "assistant", kind: "assistant", label: "Assistant context", description: "Context exposed to the assistant overlay.", required: false, multiple: false },
|
||||
|
||||
@@ -694,6 +694,10 @@ textarea.nodedc-field__control {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-range input[type="range"]:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nodedc-range__label,
|
||||
.nodedc-range__value {
|
||||
position: absolute;
|
||||
@@ -733,6 +737,43 @@ textarea.nodedc-field__control {
|
||||
color: rgb(var(--nodedc-on-accent-rgb));
|
||||
}
|
||||
|
||||
.nodedc-range__editor {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
width: max(3rem, calc((var(--nodedc-range-editor-characters) + 0.75) * 1ch));
|
||||
max-width: min(42%, 8rem);
|
||||
height: 2rem;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
padding: 0 0.25rem;
|
||||
font: inherit;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: none;
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.nodedc-range__editor[data-active] {
|
||||
color: var(--nodedc-text-primary);
|
||||
caret-color: currentColor;
|
||||
}
|
||||
|
||||
.nodedc-range[data-editing] > .nodedc-range__value,
|
||||
.nodedc-range[data-editing] > .nodedc-range__fill-text .nodedc-range__value {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nodedc-color-field {
|
||||
display: grid;
|
||||
min-height: var(--nodedc-control-height);
|
||||
@@ -2515,6 +2556,10 @@ textarea.nodedc-field__control {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.nodedc-workspace-window[data-auto-height="true"]:not([data-interaction]) {
|
||||
transition: height var(--nodedc-duration-normal) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.nodedc-workspace-window[data-active="true"] {
|
||||
box-shadow: var(--nodedc-modal-shadow), 0 0 0 1px color-mix(in srgb, var(--nodedc-text-primary) 16%, transparent);
|
||||
}
|
||||
@@ -2623,6 +2668,10 @@ textarea.nodedc-field__control {
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
.nodedc-workspace-window__body-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodedc-workspace-window__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,21 +16,29 @@ export interface InspectorSectionSpec {
|
||||
export interface InspectorProps {
|
||||
sections: InspectorSectionSpec[];
|
||||
defaultOpen?: string[];
|
||||
openSections?: string[];
|
||||
activeId?: string;
|
||||
singleOpen?: boolean;
|
||||
className?: string;
|
||||
onOpenSectionsChange?: (ids: string[]) => void;
|
||||
onActiveChange?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
sections,
|
||||
defaultOpen = [],
|
||||
openSections,
|
||||
activeId,
|
||||
singleOpen = false,
|
||||
className,
|
||||
onOpenSectionsChange,
|
||||
onActiveChange,
|
||||
}: InspectorProps) {
|
||||
const [openIds, setOpenIds] = useState(() => new Set(defaultOpen));
|
||||
const [internalOpenIds, setInternalOpenIds] = useState(() => new Set(defaultOpen));
|
||||
const openIds = useMemo(
|
||||
() => openSections === undefined ? internalOpenIds : new Set(openSections),
|
||||
[internalOpenIds, openSections],
|
||||
);
|
||||
const groups = useMemo(() => {
|
||||
const result: Array<{ label?: string; sections: InspectorSectionSpec[] }> = [];
|
||||
sections.forEach((section) => {
|
||||
@@ -45,12 +53,11 @@ export function Inspector({
|
||||
}, [sections]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setOpenIds((current) => {
|
||||
const next = singleOpen ? new Set<string>() : new Set(current);
|
||||
if (current.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const next = singleOpen ? new Set<string>() : new Set(openIds);
|
||||
if (openIds.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
if (openSections === undefined) setInternalOpenIds(next);
|
||||
onOpenSectionsChange?.([...next]);
|
||||
onActiveChange?.(id);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type CSSProperties, type InputHTMLAttributes, type PointerEvent } from "react";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
import { Dropdown } from "./Dropdown.js";
|
||||
|
||||
@@ -11,6 +11,38 @@ export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputEle
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const clampRangeValue = (value: number, min: number, max: number) => (
|
||||
Math.max(min, Math.min(max, value))
|
||||
);
|
||||
|
||||
const decimalPlaces = (value: number) => {
|
||||
const [coefficient, exponentText] = String(value).toLowerCase().split("e");
|
||||
const coefficientDecimals = coefficient?.split(".")[1]?.length ?? 0;
|
||||
if (exponentText === undefined) return coefficientDecimals;
|
||||
const exponent = Number.parseInt(exponentText, 10);
|
||||
if (!Number.isFinite(exponent)) return coefficientDecimals;
|
||||
return Math.max(0, coefficientDecimals - exponent);
|
||||
};
|
||||
|
||||
const normalizeEditedRangeValue = (
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
step: RangeControlProps["step"],
|
||||
) => {
|
||||
const clamped = clampRangeValue(value, min, max);
|
||||
if (step === "any") return clamped;
|
||||
const numericStep = step === undefined ? 1 : Number(step);
|
||||
if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped;
|
||||
const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep)));
|
||||
const aligned = min + Math.round((clamped - min) / numericStep) * numericStep;
|
||||
return clampRangeValue(Number(aligned.toFixed(precision)), min, max);
|
||||
};
|
||||
|
||||
const editableNumber = (value: number) => (
|
||||
Number.isInteger(value) ? String(value) : String(Number(value.toFixed(12)))
|
||||
);
|
||||
|
||||
export function RangeControl({
|
||||
label,
|
||||
value,
|
||||
@@ -20,31 +52,111 @@ export function RangeControl({
|
||||
formatValue = String,
|
||||
onChange,
|
||||
className,
|
||||
disabled = false,
|
||||
tabIndex,
|
||||
...props
|
||||
}: RangeControlProps) {
|
||||
const rangeRef = useRef<HTMLInputElement>(null);
|
||||
const editorRef = useRef<HTMLInputElement>(null);
|
||||
const cancelNextBlurRef = useRef(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(() => editableNumber(value));
|
||||
const safeMax = max === min ? min + 1 : max;
|
||||
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
||||
const style = { "--nodedc-range-progress": `${progress}%` } as CSSProperties;
|
||||
const displayValue = formatValue(value);
|
||||
const editorCharacters = Math.max(
|
||||
1,
|
||||
Math.min(14, editing ? draft.length || editableNumber(value).length : displayValue.length),
|
||||
);
|
||||
const style = {
|
||||
"--nodedc-range-progress": `${progress}%`,
|
||||
"--nodedc-range-editor-characters": editorCharacters,
|
||||
} as CSSProperties;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(editableNumber(value));
|
||||
}, [editing, value]);
|
||||
|
||||
const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => {
|
||||
if (commit) {
|
||||
const parsed = Number(draft.trim().replace(",", "."));
|
||||
if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step));
|
||||
}
|
||||
setEditing(false);
|
||||
if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus());
|
||||
};
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelNextBlurRef.current = true;
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(false);
|
||||
event.currentTarget.blur();
|
||||
requestAnimationFrame(() => rangeRef.current?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={cn("nodedc-range", className)} style={style}>
|
||||
<div className={cn("nodedc-range", className)} style={style} data-editing={editing || undefined}>
|
||||
<input
|
||||
ref={rangeRef}
|
||||
{...props}
|
||||
type="range"
|
||||
value={value}
|
||||
min={min}
|
||||
max={safeMax}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
tabIndex={editing ? -1 : tabIndex}
|
||||
aria-label={label}
|
||||
aria-valuetext={formatValue(value)}
|
||||
aria-valuetext={displayValue}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
{...props}
|
||||
/>
|
||||
<span className="nodedc-range__label">{label}</span>
|
||||
<span className="nodedc-range__value">{formatValue(value)}</span>
|
||||
<span className="nodedc-range__value">{displayValue}</span>
|
||||
<span className="nodedc-range__fill-text" aria-hidden="true">
|
||||
<span className="nodedc-range__label">{label}</span>
|
||||
<span className="nodedc-range__value">{formatValue(value)}</span>
|
||||
<span className="nodedc-range__value">{displayValue}</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
ref={editorRef}
|
||||
className="nodedc-range__editor"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={editing ? draft : editableNumber(value)}
|
||||
aria-label={`${label}: точное значение`}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
data-active={editing || undefined}
|
||||
onPointerDown={() => {
|
||||
if (!editing) {
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!editing) {
|
||||
setDraft(editableNumber(value));
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onBlur={() => {
|
||||
if (cancelNextBlurRef.current) {
|
||||
cancelNextBlurRef.current = false;
|
||||
return;
|
||||
}
|
||||
finishEditing(true, false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,10 +63,12 @@ export function Window({
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null);
|
||||
const shouldLockBodyScroll = lockBodyScroll ?? placement === "center";
|
||||
const shouldTrapFocus = trapFocus ?? placement === "center";
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === "undefined") return;
|
||||
@@ -82,7 +84,7 @@ export function Window({
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && closeOnEscape) {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !shouldTrapFocus || !dialogRef.current) return;
|
||||
@@ -110,7 +112,7 @@ export function Window({
|
||||
if (shouldLockBodyScroll) document.body.style.overflow = previousOverflow;
|
||||
previousActiveElement?.focus();
|
||||
};
|
||||
}, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]);
|
||||
}, [closeOnEscape, open, shouldLockBodyScroll, shouldTrapFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDragPosition(null);
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface WorkspaceWindowProps extends Omit<HTMLAttributes<HTMLDivElement
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
resizable?: boolean;
|
||||
autoHeight?: boolean;
|
||||
active?: boolean;
|
||||
zIndex?: number;
|
||||
closeLabel?: string;
|
||||
@@ -121,6 +122,7 @@ export function WorkspaceWindow({
|
||||
minWidth = 260,
|
||||
minHeight = 180,
|
||||
resizable = true,
|
||||
autoHeight = false,
|
||||
active = false,
|
||||
zIndex,
|
||||
closeLabel = "Закрыть окно",
|
||||
@@ -139,6 +141,10 @@ export function WorkspaceWindow({
|
||||
const interactionRef = useRef<WorkspaceInteraction | null>(null);
|
||||
const rectRef = useRef(rect);
|
||||
const onRectChangeRef = useRef(onRectChange);
|
||||
const headRef = useRef<HTMLElement>(null);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const bodyContentRef = useRef<HTMLDivElement>(null);
|
||||
const footerRef = useRef<HTMLElement>(null);
|
||||
const [interactionKind, setInteractionKind] = useState<WorkspaceInteraction["kind"] | null>(null);
|
||||
|
||||
rectRef.current = rect;
|
||||
@@ -175,6 +181,52 @@ export function WorkspaceWindow({
|
||||
};
|
||||
}, [boundsRef, maximized, minHeight, minWidth, rect.height, rect.width, rect.x, rect.y]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoHeight || maximized) return;
|
||||
const bounds = boundsRef.current;
|
||||
const head = headRef.current;
|
||||
const body = bodyRef.current;
|
||||
const content = bodyContentRef.current;
|
||||
if (!bounds || !head || !body || !content) return;
|
||||
let frame = 0;
|
||||
|
||||
const fitContent = () => {
|
||||
frame = 0;
|
||||
const nextBounds = readBounds();
|
||||
if (!nextBounds) return;
|
||||
const normalized = normalizeRect(rectRef.current, nextBounds, minWidth, minHeight);
|
||||
const bodyStyle = window.getComputedStyle(body);
|
||||
const bodyPadding = Number.parseFloat(bodyStyle.paddingTop || "0")
|
||||
+ Number.parseFloat(bodyStyle.paddingBottom || "0");
|
||||
const desiredHeight = Math.ceil(
|
||||
head.offsetHeight
|
||||
+ content.scrollHeight
|
||||
+ bodyPadding
|
||||
+ (footerRef.current?.offsetHeight ?? 0),
|
||||
);
|
||||
const availableHeight = Math.max(0, nextBounds.height - normalized.y);
|
||||
emitRect({
|
||||
...normalized,
|
||||
height: clampDimension(desiredHeight, minHeight, availableHeight),
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleFit = () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(fitContent);
|
||||
};
|
||||
|
||||
scheduleFit();
|
||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleFit);
|
||||
observer?.observe(content);
|
||||
observer?.observe(head);
|
||||
if (footerRef.current) observer?.observe(footerRef.current);
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [autoHeight, boundsRef, maximized, minHeight, minWidth, rect.width]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||
const interaction = interactionRef.current;
|
||||
@@ -273,6 +325,7 @@ export function WorkspaceWindow({
|
||||
data-active={active ? "true" : undefined}
|
||||
data-maximized={maximized ? "true" : undefined}
|
||||
data-resizable={resizable ? "true" : undefined}
|
||||
data-auto-height={autoHeight ? "true" : undefined}
|
||||
data-interaction={interactionKind ?? undefined}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
@@ -289,6 +342,7 @@ export function WorkspaceWindow({
|
||||
}}
|
||||
>
|
||||
<header
|
||||
ref={headRef}
|
||||
className="nodedc-workspace-window__head"
|
||||
tabIndex={maximized ? -1 : 0}
|
||||
role="group"
|
||||
@@ -326,8 +380,10 @@ export function WorkspaceWindow({
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="nodedc-workspace-window__body">{children}</div>
|
||||
{footer ? <footer className="nodedc-workspace-window__footer">{footer}</footer> : null}
|
||||
<div ref={bodyRef} className="nodedc-workspace-window__body">
|
||||
{autoHeight ? <div ref={bodyContentRef} className="nodedc-workspace-window__body-content">{children}</div> : children}
|
||||
</div>
|
||||
{footer ? <footer ref={footerRef} className="nodedc-workspace-window__footer">{footer}</footer> : null}
|
||||
{resizable && !maximized ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user