Files
editor/packages/nodes/src/elevator/panel.tsx
T
Wassim SAMADandClaude Opus 4.8 267dac7b1a perf(editor): convert UI icons from PNG to WebP
The editor's /icons assets were ~10MB of oversized PNGs (a single
toolbar icon up to 1.4MB). Convert every non-PWA icon to WebP (quality
92) and repoint all /icons/*.png references to .webp across
packages/editor, packages/nodes, and apps/editor. PWA/platform icons
(apple-touch-icon, icon-192, icon-512) stay PNG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:41:50 -04:00

938 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import {
type AnyNode,
type AnyNodeId,
type ElevatorNode,
ElevatorNode as ElevatorNodeSchema,
type LevelNode,
requestElevatorLevel,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
MetricControl,
PanelSection,
PanelWrapper,
resolveElevatorNodeSupportY,
resolveElevatorSupportY,
SliderControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Send, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
function findLevelId(levels: LevelNode[], levelId: string | null | undefined) {
if (!levelId) return null
return levels.some((level) => level.id === levelId) ? levelId : null
}
function getLegacyServedLevels(node: ElevatorNode | undefined, levels: LevelNode[]) {
if (!node || node.fromLevelId || node.toLevelId || !node.servedLevelIds?.length) return []
const servedIds = new Set(node.servedLevelIds)
return levels.filter((level) => servedIds.has(level.id))
}
function getResolvedFromLevelId(node: ElevatorNode | undefined, levels: LevelNode[]) {
if (!node) return levels[0]?.id ?? ''
const legacyServedLevels = getLegacyServedLevels(node, levels)
return (
findLevelId(levels, node.fromLevelId) ??
legacyServedLevels[0]?.id ??
findLevelId(levels, node.defaultLevelId) ??
levels[0]?.id ??
''
)
}
function getResolvedToLevelId(
node: ElevatorNode | undefined,
levels: LevelNode[],
fromLevelId: string,
) {
if (!node) return levels[0]?.id ?? ''
const explicitTo = findLevelId(levels, node.toLevelId)
if (explicitTo) return explicitTo
const legacyServedLevels = getLegacyServedLevels(node, levels)
const legacyTo = legacyServedLevels[legacyServedLevels.length - 1]?.id
if (legacyTo) return legacyTo
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
const fallbackIndex = fromIndex >= 0 ? Math.min(fromIndex + 1, levels.length - 1) : 0
return levels[fallbackIndex]?.id ?? fromLevelId
}
function getServiceLevels(levels: LevelNode[], fromLevelId: string, toLevelId: string) {
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
const toIndex = levels.findIndex((level) => level.id === toLevelId)
if (fromIndex < 0 && toIndex < 0) return []
const resolvedFromIndex = fromIndex >= 0 ? fromIndex : toIndex
const resolvedToIndex =
toIndex >= 0 ? toIndex : Math.min(Math.max(resolvedFromIndex, 0) + 1, levels.length - 1)
const minIndex = Math.min(resolvedFromIndex, resolvedToIndex)
const maxIndex = Math.max(resolvedFromIndex, resolvedToIndex)
return levels.slice(minIndex, maxIndex + 1)
}
function stripDuplicateFlags(metadata: ElevatorNode['metadata']) {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return metadata
}
const nextMeta = { ...(metadata as Record<string, unknown>) }
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta as ElevatorNode['metadata']
}
type ElevatorMetricKey =
| 'width'
| 'depth'
| 'shaftWidth'
| 'shaftDepth'
| 'shaftWallThickness'
| 'cabHeight'
| 'doorWidth'
| 'doorHeight'
type ElevatorAccessField = 'disabledLevelIds' | 'serviceOnlyLevelIds'
const DOOR_STYLE_OPTIONS: Array<{
label: string
value: ElevatorNode['doorStyle']
}> = [
{ label: 'Center opening', value: 'center-opening' },
{ label: 'Single left', value: 'single-left' },
{ label: 'Single right', value: 'single-right' },
]
const DOOR_PANEL_STYLE_OPTIONS: Array<{
label: string
value: ElevatorNode['doorPanelStyle']
}> = [
{ label: 'Glass frame', value: 'glass-frame' },
{ label: 'Solid panel', value: 'solid-panel' },
{ label: 'Segmented panel', value: 'segmented-panel' },
]
const SHAFT_STYLE_OPTIONS: Array<{
label: string
value: ElevatorNode['shaftStyle']
}> = [
{ label: 'Solid', value: 'solid' },
{ label: 'Glass', value: 'glass' },
]
function roundMeters(value: number) {
return Math.round(value * 100) / 100
}
function getResolvedShaftWidth(node: ElevatorNode) {
return Math.max(node.shaftWidth ?? node.width, node.width, 0.8)
}
function getResolvedShaftDepth(node: ElevatorNode) {
return Math.max(node.shaftDepth ?? node.depth, node.depth, 0.8)
}
function getResolvedShaftWallThickness(node: ElevatorNode) {
return Math.max(node.shaftWallThickness ?? 0.09, 0.04)
}
function radiansToDegrees(radians: number) {
return Math.round((radians * 180) / Math.PI)
}
function degreesToRadians(degrees: number) {
return (degrees * Math.PI) / 180
}
export default function ElevatorPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const runtime = useInteractive(
useShallow((s) => {
const state = selectedId ? s.elevators[selectedId as AnyNodeId] : null
if (!state) return null
return {
currentLevelId: state.currentLevelId,
requestedStops: state.requestedStops,
queue: state.queue,
targetLevelId: state.targetLevelId,
}
}),
)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ElevatorNode | undefined) : undefined,
)
const liveOverrides = useLiveNodeOverrides((s) =>
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
)
const liveTransform = useLiveTransforms((s) =>
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
)
useEffect(() => {
return () => {
if (!selectedId) return
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
}
}, [selectedId])
const levels = useScene(
useShallow((s) => {
if (!(node?.parentId && s.nodes[node.parentId as AnyNodeId]?.type === 'building')) return []
const building = s.nodes[node.parentId as AnyNodeId]
if (building?.type !== 'building') return []
return building.children
.map((childId) => s.nodes[childId as AnyNodeId])
.filter((entry): entry is LevelNode => entry?.type === 'level')
.sort((left, right) => left.level - right.level)
}),
)
const handleUpdate = useCallback(
(updates: Partial<ElevatorNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const clearLivePreview = useCallback(() => {
if (!selectedId) return
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
}, [selectedId])
useEffect(() => {
if (!(selectedId && node?.type === 'elevator')) return
const supportY = resolveElevatorNodeSupportY(node)
if (node.position[1] >= supportY - 1e-4) return
updateNode(selectedId as AnyNode['id'], {
position: [node.position[0], supportY, node.position[2]],
})
}, [
node?.defaultLevelId,
node?.fromLevelId,
node?.id,
node?.parentId,
node?.position[0],
node?.position[1],
node?.position[2],
node?.type,
selectedId,
updateNode,
node,
])
const previewMetric = useCallback(
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
if (!selectedId) return
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { [key]: value })
},
[selectedId],
)
const commitMetric = useCallback(
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
if (!selectedId) return
const hasChange = !(node && Math.abs(Number(node[key]) - Number(value)) <= 1e-6)
if (hasChange) {
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<ElevatorNode>)
}
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
},
[node, selectedId, updateNode],
)
const previewTransform = useCallback(
(position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => {
if (!selectedId) return
useLiveTransforms.getState().set(selectedId as AnyNodeId, { position, rotation })
},
[selectedId],
)
const commitTransform = useCallback(
(position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => {
if (!(selectedId && node)) return
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
const positionChanged = node.position.some(
(value, index) => Math.abs(value - position[index]!) > 1e-6,
)
const rotationChanged = Math.abs(node.rotation - rotation) > 1e-6
if (positionChanged || rotationChanged) {
updateNode(selectedId as AnyNode['id'], { position, rotation })
}
},
[node, selectedId, updateNode],
)
const getSupportedPosition = useCallback(
(x: number, z: number): ElevatorNode['position'] => {
if (!node) return [x, 0, z]
const supportY = resolveElevatorSupportY({
buildingId: node.parentId,
preferredLevelId: node.fromLevelId ?? node.defaultLevelId,
x,
z,
})
return [x, supportY, z]
},
[node],
)
const handleClose = useCallback(() => {
clearLivePreview()
setSelection({ selectedIds: [] })
}, [clearLivePreview, setSelection])
const handleMove = useCallback(() => {
if (!node) return
triggerSFX('sfx:item-pick')
clearLivePreview()
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [clearLivePreview, node, setMovingNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
triggerSFX('sfx:item-pick')
const duplicate = ElevatorNodeSchema.parse({
...structuredClone(node),
id: undefined,
name: node.name ? `${node.name} Copy` : 'Elevator Copy',
position: [node.position[0] + 1, node.position[1], node.position[2] + 1],
metadata: { ...(stripDuplicateFlags(node.metadata) as Record<string, unknown>), isNew: true },
})
createNode(duplicate, node.parentId as AnyNodeId)
clearLivePreview()
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [clearLivePreview, node, createNode, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
triggerSFX('sfx:structure-delete')
clearLivePreview()
useScene.getState().deleteNode(selectedId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [clearLivePreview, selectedId, node, setSelection])
const requestLevel = useCallback(
(levelId: LevelNode['id']) => {
if (!node) return
if ((node.disabledLevelIds ?? []).includes(levelId)) return
requestElevatorLevel(node.id as AnyNodeId, levelId as AnyNodeId)
},
[node],
)
const toggleLevelAccess = useCallback(
(field: ElevatorAccessField, levelId: LevelNode['id']) => {
if (!node) return
const disabledIds = new Set(node.disabledLevelIds ?? [])
const serviceOnlyIds = new Set(node.serviceOnlyLevelIds ?? [])
const targetSet = field === 'disabledLevelIds' ? disabledIds : serviceOnlyIds
if (targetSet.has(levelId)) {
targetSet.delete(levelId)
} else {
targetSet.add(levelId)
}
if (field === 'disabledLevelIds' && disabledIds.has(levelId)) {
serviceOnlyIds.delete(levelId)
}
if (field === 'serviceOnlyLevelIds' && serviceOnlyIds.has(levelId)) {
disabledIds.delete(levelId)
}
const nextServiceLevels = getServiceLevels(
levels,
getResolvedFromLevelId(node, levels),
getResolvedToLevelId(node, levels, getResolvedFromLevelId(node, levels)),
)
const nextDefaultLevelId =
node.defaultLevelId && !disabledIds.has(node.defaultLevelId)
? node.defaultLevelId
: (nextServiceLevels.find((level) => !disabledIds.has(level.id))?.id ??
nextServiceLevels[0]?.id ??
null)
handleUpdate({
defaultLevelId: nextDefaultLevelId,
disabledLevelIds: Array.from(disabledIds),
serviceOnlyLevelIds: Array.from(serviceOnlyIds),
})
},
[handleUpdate, levels, node],
)
const handleServiceBoundaryChange = useCallback(
(field: 'fromLevelId' | 'toLevelId', levelId: string) => {
if (!node) return
const nextFromLevelId =
field === 'fromLevelId' ? levelId : getResolvedFromLevelId(node, levels)
const nextToLevelId =
field === 'toLevelId' ? levelId : getResolvedToLevelId(node, levels, nextFromLevelId)
const nextServedLevels = getServiceLevels(levels, nextFromLevelId, nextToLevelId)
const currentDefaultIsServed = nextServedLevels.some(
(level) => level.id === node.defaultLevelId,
)
handleUpdate({
[field]: levelId || null,
defaultLevelId: currentDefaultIsServed
? node.defaultLevelId
: nextFromLevelId || nextServedLevels[0]?.id || null,
...(field === 'fromLevelId'
? {
position: [
node.position[0],
resolveElevatorSupportY({
buildingId: node.parentId,
preferredLevelId: nextFromLevelId,
x: node.position[0],
z: node.position[2],
}),
node.position[2],
] as ElevatorNode['position'],
}
: {}),
servedLevelIds: undefined,
} as Partial<ElevatorNode>)
},
[node, levels, handleUpdate],
)
if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null
const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node
const displayPosition = liveTransform?.position ?? displayNode.position
const displayRotation = liveTransform?.rotation ?? displayNode.rotation
const displayRotationDegrees = radiansToDegrees(displayRotation)
const displayShaftWidth = getResolvedShaftWidth(displayNode)
const displayShaftDepth = getResolvedShaftDepth(displayNode)
const displayShaftWallThickness = getResolvedShaftWallThickness(displayNode)
const fromLevelId = getResolvedFromLevelId(node, levels)
const toLevelId = getResolvedToLevelId(node, levels, fromLevelId)
const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId)
const servedLevelIdSet = new Set<string>(servedLevels.map((level) => level.id))
const disabledLevelIds = new Set(
(node.disabledLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)),
)
const serviceOnlyLevelIds = new Set(
(node.serviceOnlyLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)),
)
const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id))
const defaultLevelOptions =
enabledServedLevels.length > 0
? enabledServedLevels
: servedLevels.length > 0
? servedLevels
: levels
const selectedDefaultLevelId = defaultLevelOptions.some(
(level) => level.id === node.defaultLevelId,
)
? (node.defaultLevelId ?? '')
: fromLevelId
const activeLevelId =
runtime?.currentLevelId ??
(servedLevels.some((level) => level.id === node.defaultLevelId)
? node.defaultLevelId
: fromLevelId || levels[0]?.id) ??
null
const destinationOrderByLevelId = new Map<string, number>()
for (const [index, levelId] of (runtime?.requestedStops ?? []).entries()) {
destinationOrderByLevelId.set(levelId, index + 1)
}
return (
<PanelWrapper
icon="/icons/elevator.webp"
onClose={handleClose}
title={node.name || 'Elevator'}
width={300}
>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={handleDuplicate}
/>
<ActionButton
className="text-destructive hover:text-destructive"
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label="X"
max={50}
min={-50}
onChange={(value) => {
const position = getSupportedPosition(value, displayPosition[2])
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position = getSupportedPosition(value, displayPosition[2])
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[0])}
/>
<SliderControl
label="Y"
max={50}
min={-50}
onChange={(value) => {
const position: ElevatorNode['position'] = [
displayPosition[0],
value,
displayPosition[2],
]
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position: ElevatorNode['position'] = [
displayPosition[0],
value,
displayPosition[2],
]
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[1])}
/>
<SliderControl
label="Z"
max={50}
min={-50}
onChange={(value) => {
const position = getSupportedPosition(displayPosition[0], value)
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position = getSupportedPosition(displayPosition[0], value)
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[2])}
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label="Yaw"
max={180}
min={-180}
onChange={(degrees) => previewTransform(displayPosition, degreesToRadians(degrees))}
onCommit={(degrees) => commitTransform(displayPosition, degreesToRadians(degrees))}
precision={0}
restoreOnCommit={false}
step={1}
unit="°"
value={displayRotationDegrees}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
triggerSFX('sfx:item-rotate')
commitTransform(displayPosition, displayRotation - Math.PI / 4)
}}
/>
<ActionButton
label="+45°"
onClick={() => {
triggerSFX('sfx:item-rotate')
commitTransform(displayPosition, displayRotation + Math.PI / 4)
}}
/>
</div>
</PanelSection>
<PanelSection title="Service">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
From
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
onChange={(event) => handleServiceBoundaryChange('fromLevelId', event.target.value)}
value={fromLevelId}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
To
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
onChange={(event) => handleServiceBoundaryChange('toLevelId', event.target.value)}
value={toLevelId}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Default Floor
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) => handleUpdate({ defaultLevelId: event.target.value || null })}
value={selectedDefaultLevelId}
>
{defaultLevelOptions.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
</PanelSection>
<PanelSection title="Cab">
<MetricControl
label="Width"
max={4}
min={0.8}
onChange={(value) => previewMetric('width', value)}
onCommit={(value) => commitMetric('width', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.width}
/>
<MetricControl
label="Depth"
max={4}
min={0.8}
onChange={(value) => previewMetric('depth', value)}
onCommit={(value) => commitMetric('depth', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.depth}
/>
<MetricControl
label="Cab Height"
max={4}
min={1.8}
onChange={(value) => previewMetric('cabHeight', value)}
onCommit={(value) => commitMetric('cabHeight', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.cabHeight}
/>
</PanelSection>
<PanelSection title="Shaft">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Shaft Style
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({ shaftStyle: event.target.value as ElevatorNode['shaftStyle'] })
}
value={displayNode.shaftStyle ?? 'solid'}
>
{SHAFT_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<MetricControl
label="Shaft Width"
max={5}
min={displayNode.width}
onChange={(value) => previewMetric('shaftWidth', Math.max(value, displayNode.width))}
onCommit={(value) => commitMetric('shaftWidth', Math.max(value, displayNode.width))}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayShaftWidth}
/>
<MetricControl
label="Shaft Depth"
max={5}
min={displayNode.depth}
onChange={(value) => previewMetric('shaftDepth', Math.max(value, displayNode.depth))}
onCommit={(value) => commitMetric('shaftDepth', Math.max(value, displayNode.depth))}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayShaftDepth}
/>
<MetricControl
label="Wall Thickness"
max={0.4}
min={0.04}
onChange={(value) => previewMetric('shaftWallThickness', value)}
onCommit={(value) => commitMetric('shaftWallThickness', value)}
precision={2}
restoreOnCommit={false}
step={0.01}
unit="m"
value={displayShaftWallThickness}
/>
</PanelSection>
<PanelSection title="Doors">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Opening Style
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({ doorStyle: event.target.value as ElevatorNode['doorStyle'] })
}
value={displayNode.doorStyle ?? 'center-opening'}
>
{DOOR_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Door Type
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({
doorPanelStyle: event.target.value as ElevatorNode['doorPanelStyle'],
})
}
value={displayNode.doorPanelStyle ?? 'glass-frame'}
>
{DOOR_PANEL_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<MetricControl
label="Door Width"
max={Math.max(displayNode.width - 0.1, 0.5)}
min={0.45}
onChange={(value) => previewMetric('doorWidth', value)}
onCommit={(value) => commitMetric('doorWidth', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.doorWidth}
/>
<MetricControl
label="Door Height"
max={Math.max(displayNode.cabHeight - 0.1, 1.3)}
min={1.2}
onChange={(value) => previewMetric('doorHeight', value)}
onCommit={(value) => commitMetric('doorHeight', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.doorHeight}
/>
</PanelSection>
<PanelSection title="Access">
<div className="space-y-2">
{servedLevels.map((level) => {
const isDisabled = disabledLevelIds.has(level.id)
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
return (
<div
className="flex items-center justify-between gap-2 rounded-lg border border-border/45 bg-[#2C2C2E] px-2.5 py-2"
key={level.id}
>
<span className="min-w-0 truncate text-sm">
{level.name || `Level ${level.level}`}
</span>
<div className="flex shrink-0 gap-1.5">
<button
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
isServiceOnly
? 'border-sky-300/45 bg-sky-400/15 text-sky-100'
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
} ${isDisabled ? 'cursor-not-allowed opacity-45' : ''}`}
disabled={isDisabled}
onClick={() => toggleLevelAccess('serviceOnlyLevelIds', level.id)}
type="button"
>
Service
</button>
<button
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
isDisabled
? 'border-red-300/45 bg-red-400/15 text-red-100'
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
}`}
onClick={() => toggleLevelAccess('disabledLevelIds', level.id)}
type="button"
>
Disabled
</button>
</div>
</div>
)
})}
</div>
</PanelSection>
<PanelSection title="Destination">
<div className="grid grid-cols-2 gap-1.5">
{servedLevels.map((level) => {
const isActive = activeLevelId === level.id
const stopOrder = destinationOrderByLevelId.get(level.id)
const isDisabled = disabledLevelIds.has(level.id)
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
return (
<button
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
isDisabled
? 'cursor-not-allowed border-border/35 bg-[#202024] text-muted-foreground/55'
: isActive
? 'border-emerald-400/45 bg-emerald-400/15 text-emerald-100'
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
}`}
disabled={isDisabled}
key={level.id}
onClick={() => requestLevel(level.id)}
type="button"
>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs">{level.name || `Level ${level.level}`}</span>
{isDisabled ? (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Disabled
</span>
) : isServiceOnly ? (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Service
</span>
) : (
stopOrder && (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Stop {stopOrder}
</span>
)
)}
</span>
<span
className={`flex h-6 min-w-6 items-center justify-center rounded-full border border-white/15 bg-black/20 ${
stopOrder ? 'px-1.5 font-mono text-[11px] font-semibold' : ''
}`}
>
{isDisabled ? '×' : (stopOrder ?? <Send className="h-3 w-3" />)}
</span>
</button>
)
})}
</div>
</PanelSection>
<PanelSection title="Motion">
<SliderControl
label="Speed"
max={8}
min={0.5}
onChange={(value) => handleUpdate({ speed: value })}
precision={1}
step={0.1}
unit="m/s"
value={node.speed}
/>
<SliderControl
label="Door Time"
max={2200}
min={300}
onChange={(value) => handleUpdate({ doorDurationMs: value })}
step={50}
unit="ms"
value={node.doorDurationMs}
/>
<SliderControl
label="Dwell"
max={5000}
min={300}
onChange={(value) => handleUpdate({ dwellMs: value })}
step={100}
unit="ms"
value={node.dwellMs}
/>
</PanelSection>
</PanelWrapper>
)
}