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>
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
'use client'
|
||
|
||
import {
|
||
type AnyNode,
|
||
type AnyNodeId,
|
||
type RoofSegmentNode,
|
||
SOLAR_PANEL_PRESET_LABELS,
|
||
SOLAR_PANEL_PRESETS,
|
||
type SolarPanelNode,
|
||
type SolarPanelPresetKey,
|
||
useLiveNodeOverrides,
|
||
useScene,
|
||
} from '@pascal-app/core'
|
||
import {
|
||
ActionButton,
|
||
ActionGroup,
|
||
PanelSection,
|
||
PanelWrapper,
|
||
SegmentedControl,
|
||
SliderControl,
|
||
triggerSFX,
|
||
} from '@pascal-app/editor'
|
||
import { useViewer } from '@pascal-app/viewer'
|
||
import { LayoutGrid, Trash2 } from 'lucide-react'
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { computeAutoFit, flippedPanelDims } from './geometry'
|
||
|
||
const cn = (...classes: Array<string | false | undefined | null>): string =>
|
||
classes.filter(Boolean).join(' ')
|
||
|
||
// Editing any of these clears panelTypePreset back to undefined ("Custom"),
|
||
// keeping the schema invariant: a present preset always matches the table.
|
||
const PRESET_OWNED_FIELDS: ReadonlyArray<keyof SolarPanelNode> = [
|
||
'panelWidth',
|
||
'panelHeight',
|
||
'frameThickness',
|
||
'frameDepth',
|
||
]
|
||
|
||
const PRESET_CARDS: { key: SolarPanelPresetKey; label: string }[] = [
|
||
{ key: 'residential', label: SOLAR_PANEL_PRESET_LABELS.residential },
|
||
{ key: 'residential-large', label: SOLAR_PANEL_PRESET_LABELS['residential-large'] },
|
||
{ key: 'compact', label: SOLAR_PANEL_PRESET_LABELS.compact },
|
||
{ key: 'frameless', label: SOLAR_PANEL_PRESET_LABELS.frameless },
|
||
]
|
||
|
||
function dimsTouchedByUpdate(updates: Partial<SolarPanelNode>): boolean {
|
||
for (const field of PRESET_OWNED_FIELDS) {
|
||
if (field in updates) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function num(value: unknown, fallback: number): number {
|
||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||
}
|
||
|
||
export default function SolarPanelPanel() {
|
||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||
const setSelection = useViewer((s) => s.setSelection)
|
||
const updateNode = useScene((s) => s.updateNode)
|
||
const deleteNode = useScene((s) => s.deleteNode)
|
||
|
||
const storeNode = useScene((s) =>
|
||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SolarPanelNode | undefined) : undefined,
|
||
)
|
||
const overrides = useLiveNodeOverrides((s) =>
|
||
selectedId
|
||
? (s.get(selectedId as AnyNodeId) as Partial<SolarPanelNode> | undefined)
|
||
: undefined,
|
||
)
|
||
const node =
|
||
storeNode && overrides ? ({ ...storeNode, ...overrides } as SolarPanelNode) : storeNode
|
||
const segment = useScene((s) =>
|
||
node?.roofSegmentId
|
||
? (s.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||
: undefined,
|
||
)
|
||
|
||
const [autoFitMessage, setAutoFitMessage] = useState<string | null>(null)
|
||
useEffect(() => {
|
||
if (!autoFitMessage) return
|
||
const t = window.setTimeout(() => setAutoFitMessage(null), 3500)
|
||
return () => window.clearTimeout(t)
|
||
}, [autoFitMessage])
|
||
|
||
const handleUpdate = useCallback(
|
||
(updates: Partial<SolarPanelNode>) => {
|
||
if (!selectedId) return
|
||
const next: Partial<SolarPanelNode> = dimsTouchedByUpdate(updates)
|
||
? { ...updates, panelTypePreset: undefined }
|
||
: updates
|
||
updateNode(selectedId as AnyNode['id'], next)
|
||
},
|
||
[selectedId, updateNode],
|
||
)
|
||
|
||
const previewProp = useCallback(
|
||
(updates: Partial<SolarPanelNode>) => {
|
||
if (!selectedId) return
|
||
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates)
|
||
},
|
||
[selectedId],
|
||
)
|
||
const commitProp = useCallback(
|
||
(updates: Partial<SolarPanelNode>) => {
|
||
if (!selectedId) return
|
||
const next: Partial<SolarPanelNode> = dimsTouchedByUpdate(updates)
|
||
? { ...updates, panelTypePreset: undefined }
|
||
: updates
|
||
updateNode(selectedId as AnyNode['id'], next)
|
||
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||
},
|
||
[selectedId, updateNode],
|
||
)
|
||
|
||
const handleClose = useCallback(() => {
|
||
setSelection({ selectedIds: [] })
|
||
}, [setSelection])
|
||
|
||
const handleBack = useCallback(() => {
|
||
if (node?.roofSegmentId) {
|
||
setSelection({ selectedIds: [node.roofSegmentId as AnyNode['id']] })
|
||
}
|
||
}, [node?.roofSegmentId, setSelection])
|
||
|
||
const handlePresetChange = useCallback(
|
||
(key: SolarPanelPresetKey) => {
|
||
if (!selectedId) return
|
||
const dims = SOLAR_PANEL_PRESETS[key]
|
||
updateNode(selectedId as AnyNode['id'], { panelTypePreset: key, ...dims })
|
||
},
|
||
[selectedId, updateNode],
|
||
)
|
||
|
||
const handleFlip = useCallback(() => {
|
||
if (!(selectedId && node)) return
|
||
updateNode(selectedId as AnyNode['id'], {
|
||
...flippedPanelDims(node),
|
||
panelTypePreset: undefined,
|
||
})
|
||
}, [selectedId, node, updateNode])
|
||
|
||
const handleAutoFit = useCallback(() => {
|
||
if (!(selectedId && node && segment)) return
|
||
const fit = computeAutoFit(segment, node)
|
||
if (!fit) {
|
||
setAutoFitMessage('Setbacks too large to fit a panel.')
|
||
return
|
||
}
|
||
updateNode(selectedId as AnyNode['id'], { rows: fit.rows, columns: fit.columns })
|
||
setAutoFitMessage(null)
|
||
}, [selectedId, node, segment, updateNode])
|
||
|
||
const handleDelete = useCallback(() => {
|
||
if (!(selectedId && node)) return
|
||
triggerSFX('sfx:item-delete')
|
||
const segmentId = node.roofSegmentId
|
||
if (segmentId) {
|
||
const state = useScene.getState()
|
||
const seg = state.nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
|
||
if (seg) {
|
||
state.updateNode(segmentId as AnyNode['id'], {
|
||
children: (seg.children ?? []).filter((id) => id !== selectedId),
|
||
})
|
||
}
|
||
}
|
||
deleteNode(selectedId as AnyNodeId)
|
||
if (segmentId) {
|
||
useScene.getState().dirtyNodes.add(segmentId as AnyNodeId)
|
||
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
||
} else {
|
||
setSelection({ selectedIds: [] })
|
||
}
|
||
}, [selectedId, node, deleteNode, setSelection])
|
||
|
||
if (!(node && node.type === 'solar-panel' && selectedId)) return null
|
||
|
||
const activePreset = node.panelTypePreset
|
||
const formatDims = (w: number, h: number) => `${w.toFixed(2)} × ${h.toFixed(2)} m`
|
||
|
||
return (
|
||
<PanelWrapper
|
||
icon="/icons/roof.webp"
|
||
onBack={node.roofSegmentId ? handleBack : undefined}
|
||
onClose={handleClose}
|
||
title={node.name || 'Solar Panel'}
|
||
width={300}
|
||
>
|
||
<PanelSection title="Preset">
|
||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||
{PRESET_CARDS.map((card) => {
|
||
const dims = SOLAR_PANEL_PRESETS[card.key]
|
||
const isSelected = activePreset === card.key
|
||
return (
|
||
<button
|
||
className={cn(
|
||
'flex min-h-14 flex-col items-start gap-0.5 rounded-lg border px-2.5 py-2 text-left text-xs transition-colors',
|
||
isSelected
|
||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||
)}
|
||
key={card.key}
|
||
onClick={() => handlePresetChange(card.key)}
|
||
type="button"
|
||
>
|
||
<span className="flex items-center gap-1.5 font-medium">
|
||
<LayoutGrid className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="truncate">{card.label}</span>
|
||
</span>
|
||
<span className="pl-[20px] text-[10px] tabular-nums opacity-70">
|
||
{formatDims(dims.panelWidth, dims.panelHeight)}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
{!activePreset && (
|
||
<p className="px-1 pt-1 text-[11px] text-muted-foreground">
|
||
Custom — dimensions don't match any preset
|
||
</p>
|
||
)}
|
||
</PanelSection>
|
||
|
||
<PanelSection title="Array">
|
||
<SliderControl
|
||
label="Rows"
|
||
max={20}
|
||
min={1}
|
||
onChange={(v) => previewProp({ rows: Math.round(v) })}
|
||
onCommit={(v) => commitProp({ rows: Math.round(v) })}
|
||
precision={0}
|
||
restoreOnCommit={false}
|
||
step={1}
|
||
value={num(node.rows, 4)}
|
||
/>
|
||
<SliderControl
|
||
label="Columns"
|
||
max={20}
|
||
min={1}
|
||
onChange={(v) => previewProp({ columns: Math.round(v) })}
|
||
onCommit={(v) => commitProp({ columns: Math.round(v) })}
|
||
precision={0}
|
||
restoreOnCommit={false}
|
||
step={1}
|
||
value={num(node.columns, 5)}
|
||
/>
|
||
<SliderControl
|
||
label="Gap X"
|
||
max={0.2}
|
||
min={0}
|
||
onChange={(v) => previewProp({ gapX: v })}
|
||
onCommit={(v) => commitProp({ gapX: v })}
|
||
precision={3}
|
||
restoreOnCommit={false}
|
||
step={0.005}
|
||
unit="m"
|
||
value={Math.round(num(node.gapX, 0.02) * 1000) / 1000}
|
||
/>
|
||
<SliderControl
|
||
label="Gap Y"
|
||
max={0.2}
|
||
min={0}
|
||
onChange={(v) => previewProp({ gapY: v })}
|
||
onCommit={(v) => commitProp({ gapY: v })}
|
||
precision={3}
|
||
restoreOnCommit={false}
|
||
step={0.005}
|
||
unit="m"
|
||
value={Math.round(num(node.gapY, 0.02) * 1000) / 1000}
|
||
/>
|
||
<ActionGroup>
|
||
<ActionButton disabled={!segment} label="Auto-fit to roof" onClick={handleAutoFit} />
|
||
</ActionGroup>
|
||
{autoFitMessage ? <p className="px-1 text-amber-400 text-xs">{autoFitMessage}</p> : null}
|
||
</PanelSection>
|
||
|
||
<PanelSection title="Panel">
|
||
<SliderControl
|
||
label="Width"
|
||
max={2.5}
|
||
min={0.3}
|
||
onChange={(v) => previewProp({ panelWidth: v })}
|
||
onCommit={(v) => commitProp({ panelWidth: v })}
|
||
precision={2}
|
||
restoreOnCommit={false}
|
||
step={0.05}
|
||
unit="m"
|
||
value={Math.round(num(node.panelWidth, 1) * 100) / 100}
|
||
/>
|
||
<SliderControl
|
||
label="Height"
|
||
max={3}
|
||
min={0.3}
|
||
onChange={(v) => previewProp({ panelHeight: v })}
|
||
onCommit={(v) => commitProp({ panelHeight: v })}
|
||
precision={2}
|
||
restoreOnCommit={false}
|
||
step={0.05}
|
||
unit="m"
|
||
value={Math.round(num(node.panelHeight, 1.65) * 100) / 100}
|
||
/>
|
||
<ActionGroup>
|
||
<ActionButton label="Flip orientation" onClick={handleFlip} />
|
||
</ActionGroup>
|
||
<SliderControl
|
||
label="Frame thickness"
|
||
max={0.1}
|
||
min={0.005}
|
||
onChange={(v) => previewProp({ frameThickness: v })}
|
||
onCommit={(v) => commitProp({ frameThickness: v })}
|
||
precision={3}
|
||
restoreOnCommit={false}
|
||
step={0.005}
|
||
unit="m"
|
||
value={Math.round(num(node.frameThickness, 0.04) * 1000) / 1000}
|
||
/>
|
||
<SliderControl
|
||
label="Frame depth"
|
||
max={0.1}
|
||
min={0.005}
|
||
onChange={(v) => previewProp({ frameDepth: v })}
|
||
onCommit={(v) => commitProp({ frameDepth: v })}
|
||
precision={3}
|
||
restoreOnCommit={false}
|
||
step={0.005}
|
||
unit="m"
|
||
value={Math.round(num(node.frameDepth, 0.04) * 1000) / 1000}
|
||
/>
|
||
</PanelSection>
|
||
|
||
<PanelSection title="Mounting">
|
||
<SegmentedControl
|
||
onChange={(v) => handleUpdate({ mountingType: v })}
|
||
options={[
|
||
{ label: 'Flush', value: 'flush' },
|
||
{ label: 'Tilted', value: 'tilted' },
|
||
]}
|
||
value={node.mountingType ?? 'flush'}
|
||
/>
|
||
{node.mountingType === 'tilted' && (
|
||
<SliderControl
|
||
label="Tilt angle"
|
||
max={45}
|
||
min={0}
|
||
onChange={(v) => previewProp({ tiltAngle: v })}
|
||
onCommit={(v) => commitProp({ tiltAngle: v })}
|
||
precision={0}
|
||
restoreOnCommit={false}
|
||
step={1}
|
||
unit="°"
|
||
value={Math.round(num(node.tiltAngle, 15))}
|
||
/>
|
||
)}
|
||
<SliderControl
|
||
label="Standoff"
|
||
max={0.3}
|
||
min={0}
|
||
onChange={(v) => previewProp({ standoffHeight: v })}
|
||
onCommit={(v) => commitProp({ standoffHeight: v })}
|
||
precision={3}
|
||
restoreOnCommit={false}
|
||
step={0.005}
|
||
unit="m"
|
||
value={Math.round(num(node.standoffHeight, 0.05) * 1000) / 1000}
|
||
/>
|
||
</PanelSection>
|
||
|
||
<PanelSection title="Actions">
|
||
<ActionGroup>
|
||
<ActionButton
|
||
className="hover:bg-red-500/20"
|
||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||
label="Delete"
|
||
onClick={handleDelete}
|
||
/>
|
||
</ActionGroup>
|
||
</PanelSection>
|
||
</PanelWrapper>
|
||
)
|
||
}
|