feat(viewer): re-light + re-control baked GLBs from the scene graph
Add a GLB interactive layer (`GlbInteractive`) that re-creates the item interactivity the parametric viewer has — point lights and the controls overlay — on top of a baked artifact. Effects + controls come from the DB scene graph (joined to baked nodes by `pascalId`, no sidecar); world transforms come from the baked Object3Ds. Lights are portaled into their item node so they ride level stacking, and intensity tracks the shared `useInteractive` store so overlay dimming works. Baked scenes load "lit" (toggles default on) for a showcase viewer feel. Extract `ControlWidget` into its own module so the parametric `InteractiveSystem` and the GLB overlay render identical controls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0e55d86416
commit
d80ffd67d5
@@ -0,0 +1,270 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type Control,
|
||||||
|
type ControlValue,
|
||||||
|
type Interactive,
|
||||||
|
type LightEffect,
|
||||||
|
pointInPolygon,
|
||||||
|
type SceneGraph,
|
||||||
|
type SliderControl,
|
||||||
|
useInteractive,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
import { createPortal } from '@react-three/fiber'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { type Object3D, Vector3 } from 'three'
|
||||||
|
import { lerp } from 'three/src/math/MathUtils.js'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import { ControlWidget } from '../../systems/interactive/control-widget'
|
||||||
|
|
||||||
|
/** An interactive item recovered from the scene graph so the baked GLB can be
|
||||||
|
* re-lit / re-animated by joining on `pascalId`. The GLB carries the geometry
|
||||||
|
* + identity; the effects + controls live in the DB scene graph (no sidecar). */
|
||||||
|
export type GlbInteractiveItem = {
|
||||||
|
pascalId: AnyNodeId
|
||||||
|
label: string
|
||||||
|
/** Item height (world units) for placing the controls overlay above it. */
|
||||||
|
height: number
|
||||||
|
interactive: Interactive
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A baked zone's identity node + its local floor polygon (from `extras`). */
|
||||||
|
export type GlbZoneRef = {
|
||||||
|
id: string
|
||||||
|
node: Object3D
|
||||||
|
polygon: [number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the interactive items out of a scene graph. Only items that actually
|
||||||
|
* carry effects (light / animation) are returned — everything else baked
|
||||||
|
* faithfully and needs no runtime help. */
|
||||||
|
export function buildGlbInteractiveItems(
|
||||||
|
sceneGraph: SceneGraph | null | undefined,
|
||||||
|
): GlbInteractiveItem[] {
|
||||||
|
const nodes = sceneGraph?.nodes
|
||||||
|
if (!nodes) return []
|
||||||
|
const items: GlbInteractiveItem[] = []
|
||||||
|
for (const [id, raw] of Object.entries(nodes)) {
|
||||||
|
const node = raw as {
|
||||||
|
type?: string
|
||||||
|
scale?: [number, number, number]
|
||||||
|
asset?: { name?: string; dimensions?: [number, number, number]; interactive?: Interactive }
|
||||||
|
}
|
||||||
|
if (node?.type !== 'item') continue
|
||||||
|
const interactive = node.asset?.interactive
|
||||||
|
if (!interactive?.effects?.length) continue
|
||||||
|
const dims = node.asset?.dimensions ?? [1, 1, 1]
|
||||||
|
const scaleY = node.scale?.[1] ?? 1
|
||||||
|
items.push({
|
||||||
|
pascalId: id as AnyNodeId,
|
||||||
|
label: node.asset?.name ?? id,
|
||||||
|
height: (dims[1] ?? 1) * scaleY,
|
||||||
|
interactive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Light intensity for the current control state. Mirrors the parametric
|
||||||
|
* `ItemLightSystem`: a missing toggle/slider value means the viewer default
|
||||||
|
* (lit / full). An explicit toggle-off drops to the range minimum. */
|
||||||
|
function resolveLightIntensity(
|
||||||
|
effect: LightEffect,
|
||||||
|
controls: Control[],
|
||||||
|
values: ControlValue[] | undefined,
|
||||||
|
): number {
|
||||||
|
const toggleIndex = controls.findIndex((c) => c.kind === 'toggle')
|
||||||
|
const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex] ?? true) : true
|
||||||
|
if (!isOn) return effect.intensityRange[0]
|
||||||
|
const sliderIndex = controls.findIndex((c) => c.kind === 'slider')
|
||||||
|
let t = 1
|
||||||
|
if (sliderIndex >= 0) {
|
||||||
|
const slider = controls[sliderIndex] as SliderControl
|
||||||
|
const raw = (values?.[sliderIndex] as number) ?? slider.default ?? slider.max
|
||||||
|
t = slider.max > slider.min ? (raw - slider.min) / (slider.max - slider.min) : 1
|
||||||
|
}
|
||||||
|
return lerp(effect.intensityRange[0], effect.intensityRange[1], t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const _itemPos = new Vector3()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-creates the item-driven interactivity the parametric viewer has — lights
|
||||||
|
* and (later) ambient animation + the controls overlay — on top of a baked
|
||||||
|
* GLB. Effects come from the DB scene graph (`items`); world transforms come
|
||||||
|
* from the baked Object3Ds (`identity`), joined on `pascalId`. Nothing is
|
||||||
|
* stamped into the GLB itself, so the artifact stays integrator-clean.
|
||||||
|
*/
|
||||||
|
export function GlbInteractive({
|
||||||
|
items,
|
||||||
|
identity,
|
||||||
|
zones,
|
||||||
|
}: {
|
||||||
|
items: GlbInteractiveItem[]
|
||||||
|
identity: Map<string, Object3D>
|
||||||
|
zones: GlbZoneRef[]
|
||||||
|
}) {
|
||||||
|
// Seed control state for every interactive item. The viewer shows a baked
|
||||||
|
// scene "lit": toggles default ON (the editor defaults them off) and sliders
|
||||||
|
// to their authored default, so lamps glow and fans spin on load. Explicit
|
||||||
|
// overlay toggles then win. Cleared on unmount so the global store never
|
||||||
|
// carries state across scenes.
|
||||||
|
useEffect(() => {
|
||||||
|
const store = useInteractive.getState()
|
||||||
|
for (const item of items) {
|
||||||
|
store.initItem(item.pascalId, item.interactive)
|
||||||
|
item.interactive.controls.forEach((control, i) => {
|
||||||
|
if (control.kind === 'toggle') store.setControlValue(item.pascalId, i, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
const store = useInteractive.getState()
|
||||||
|
for (const item of items) store.removeItem(item.pascalId)
|
||||||
|
}
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const lightItems = useMemo(
|
||||||
|
() => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'light')),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Controls overlay is scoped to the focused zone (matches the parametric
|
||||||
|
// viewer). Project the zone's baked-local polygon into world space once so an
|
||||||
|
// item's world position can be point-tested regardless of level stacking.
|
||||||
|
const focusedZoneId = useViewer((s) => s.selection.zoneId)
|
||||||
|
const worldPolygon = useMemo<[number, number][] | null>(() => {
|
||||||
|
if (!focusedZoneId) return null
|
||||||
|
const zone = zones.find((z) => z.id === focusedZoneId)
|
||||||
|
if (!zone) return null
|
||||||
|
zone.node.updateWorldMatrix(true, false)
|
||||||
|
return zone.polygon.map(([x, z]) => {
|
||||||
|
const v = new Vector3(x, 0, z).applyMatrix4(zone.node.matrixWorld)
|
||||||
|
return [v.x, v.z]
|
||||||
|
})
|
||||||
|
}, [focusedZoneId, zones])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{lightItems.map((item) => {
|
||||||
|
const object = identity.get(item.pascalId)
|
||||||
|
return object ? <GlbItemLight item={item} key={item.pascalId} object={object} /> : null
|
||||||
|
})}
|
||||||
|
{items.map((item) => {
|
||||||
|
const object = identity.get(item.pascalId)
|
||||||
|
return object ? (
|
||||||
|
<GlbItemControls
|
||||||
|
item={item}
|
||||||
|
key={item.pascalId}
|
||||||
|
object={object}
|
||||||
|
worldPolygon={worldPolygon}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One point light, portaled into its item's baked node so it rides level
|
||||||
|
* stacking. Intensity tracks the shared interactive store (overlay dimming). */
|
||||||
|
function GlbItemLight({ item, object }: { item: GlbInteractiveItem; object: Object3D }) {
|
||||||
|
const values = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues))
|
||||||
|
const effect = item.interactive.effects.find((e) => e.kind === 'light') as LightEffect | undefined
|
||||||
|
if (!effect) return null
|
||||||
|
const intensity = resolveLightIntensity(effect, item.interactive.controls, values)
|
||||||
|
return createPortal(
|
||||||
|
<pointLight
|
||||||
|
castShadow={false}
|
||||||
|
color={effect.color}
|
||||||
|
decay={2}
|
||||||
|
distance={effect.distance ?? 0}
|
||||||
|
intensity={intensity}
|
||||||
|
position={effect.offset}
|
||||||
|
/>,
|
||||||
|
object,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FADE_MS = 300
|
||||||
|
|
||||||
|
/** Controls overlay for one item — fades in while the item sits inside the
|
||||||
|
* focused zone, portaled above the baked node. */
|
||||||
|
function GlbItemControls({
|
||||||
|
item,
|
||||||
|
object,
|
||||||
|
worldPolygon,
|
||||||
|
}: {
|
||||||
|
item: GlbInteractiveItem
|
||||||
|
object: Object3D
|
||||||
|
worldPolygon: [number, number][] | null
|
||||||
|
}) {
|
||||||
|
const controlValues = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues))
|
||||||
|
const setControlValue = useInteractive((s) => s.setControlValue)
|
||||||
|
|
||||||
|
let visible = false
|
||||||
|
if (worldPolygon?.length) {
|
||||||
|
object.getWorldPosition(_itemPos)
|
||||||
|
visible = pointInPolygon(_itemPos.x, _itemPos.z, worldPolygon)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade in on mount and fade out before unmounting the <Html>.
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const [shown, setShown] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setMounted(true)
|
||||||
|
let raf2 = 0
|
||||||
|
const raf1 = requestAnimationFrame(() => {
|
||||||
|
raf2 = requestAnimationFrame(() => setShown(true))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf1)
|
||||||
|
cancelAnimationFrame(raf2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShown(false)
|
||||||
|
const timeout = setTimeout(() => setMounted(false), FADE_MS)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
if (!(mounted && controlValues)) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<Html
|
||||||
|
center
|
||||||
|
distanceFactor={8}
|
||||||
|
eps={-1}
|
||||||
|
position={[0, item.height + 0.3, 0]}
|
||||||
|
zIndexRange={[20, 0]}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
background: 'rgba(0,0,0,0.75)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
minWidth: 120,
|
||||||
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
opacity: shown ? 1 : 0,
|
||||||
|
transition: `opacity ${FADE_MS}ms ease`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.interactive.controls.map((control, i) => (
|
||||||
|
<ControlWidget
|
||||||
|
control={control}
|
||||||
|
key={i}
|
||||||
|
onChange={(v) => setControlValue(item.pascalId, i, v)}
|
||||||
|
value={controlValues[i] ?? false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Html>,
|
||||||
|
object,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
|
|||||||
import { ZONE_LAYER } from '../../lib/layers'
|
import { ZONE_LAYER } from '../../lib/layers'
|
||||||
import { createSurfaceRoleMaterial } from '../../lib/materials'
|
import { createSurfaceRoleMaterial } from '../../lib/materials'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import { GlbInteractive, type GlbInteractiveItem } from './glb-interactive'
|
||||||
|
|
||||||
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
|
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
|
||||||
const EXPLODED_GAP = 5
|
const EXPLODED_GAP = 5
|
||||||
@@ -279,12 +280,16 @@ function createZoneWallGeometry(polygon: [number, number][]): THREE.BufferGeomet
|
|||||||
*/
|
*/
|
||||||
export function GlbScene({
|
export function GlbScene({
|
||||||
url,
|
url,
|
||||||
|
interactiveItems,
|
||||||
onLevelsChange,
|
onLevelsChange,
|
||||||
onIdentityChange,
|
onIdentityChange,
|
||||||
onHoverChange,
|
onHoverChange,
|
||||||
onWalkthroughChange,
|
onWalkthroughChange,
|
||||||
}: {
|
}: {
|
||||||
url: string
|
url: string
|
||||||
|
/** Light / animation effects + controls recovered from the DB scene graph,
|
||||||
|
* joined to the baked nodes by `pascalId` to re-light + re-animate the GLB. */
|
||||||
|
interactiveItems?: GlbInteractiveItem[]
|
||||||
onLevelsChange?: (levels: GlbLevel[]) => void
|
onLevelsChange?: (levels: GlbLevel[]) => void
|
||||||
onIdentityChange?: (identity: GlbIdentity) => void
|
onIdentityChange?: (identity: GlbIdentity) => void
|
||||||
onHoverChange?: (hover: GlbHover) => void
|
onHoverChange?: (hover: GlbHover) => void
|
||||||
@@ -972,6 +977,11 @@ export function GlbScene({
|
|||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerOut={handlePointerOut}
|
onPointerOut={handlePointerOut}
|
||||||
/>
|
/>
|
||||||
|
{/* Re-light + re-animate the baked artifact from the DB scene graph,
|
||||||
|
joined to the baked nodes by pascalId. */}
|
||||||
|
{interactiveItems?.length ? (
|
||||||
|
<GlbInteractive identity={identity} items={interactiveItems} zones={zoneEntries} />
|
||||||
|
) : null}
|
||||||
{/* Floating room labels. Each group's matrix is synced to its zone node
|
{/* Floating room labels. Each group's matrix is synced to its zone node
|
||||||
every frame (above) so the label rides level stacking; the div fades
|
every frame (above) so the label rides level stacking; the div fades
|
||||||
with the room fill via a CSS transition. */}
|
with the room fill via a CSS transition. */}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export {
|
|||||||
default as BVHEcctrl,
|
default as BVHEcctrl,
|
||||||
type MovementInput,
|
type MovementInput,
|
||||||
} from './components/viewer/bvh-ecctrl'
|
} from './components/viewer/bvh-ecctrl'
|
||||||
|
export {
|
||||||
|
buildGlbInteractiveItems,
|
||||||
|
GlbInteractive,
|
||||||
|
type GlbInteractiveItem,
|
||||||
|
} from './components/viewer/glb-interactive'
|
||||||
export {
|
export {
|
||||||
type GlbHover,
|
type GlbHover,
|
||||||
type GlbIdentity,
|
type GlbIdentity,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { Control, ControlValue } from '@pascal-app/core'
|
||||||
|
|
||||||
|
/** One interactive control (toggle / slider / temperature) rendered inside the
|
||||||
|
* item controls overlay. Shared by the parametric `InteractiveSystem` and the
|
||||||
|
* baked-GLB `GlbInteractive` overlay so both look and behave identically. */
|
||||||
|
export const ControlWidget = ({
|
||||||
|
control,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
control: Control
|
||||||
|
value: ControlValue
|
||||||
|
onChange: (v: ControlValue) => void
|
||||||
|
}) => {
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.kind === 'toggle') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(!value)}
|
||||||
|
style={{
|
||||||
|
background: value ? '#4ade80' : '#374151',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{control.label ?? (value ? 'On' : 'Off')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.kind === 'slider') {
|
||||||
|
return (
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span>
|
||||||
|
{control.label}: {value}
|
||||||
|
{control.unit ? ` ${control.unit}` : ''}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
max={control.max}
|
||||||
|
min={control.min}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
step={control.step}
|
||||||
|
type="range"
|
||||||
|
value={value as number}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.kind === 'temperature') {
|
||||||
|
return (
|
||||||
|
<label style={labelStyle}>
|
||||||
|
<span>
|
||||||
|
{control.label}: {value}°{control.unit}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
max={control.max}
|
||||||
|
min={control.min}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
step={1}
|
||||||
|
type="range"
|
||||||
|
value={value as number}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type Control,
|
|
||||||
type ControlValue,
|
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
pointInPolygon,
|
pointInPolygon,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
@@ -17,6 +15,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { type Object3D, Vector3 } from 'three'
|
import { type Object3D, Vector3 } from 'three'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import { ControlWidget } from './control-widget'
|
||||||
|
|
||||||
const _tempVec = new Vector3()
|
const _tempVec = new Vector3()
|
||||||
|
|
||||||
@@ -146,86 +145,3 @@ const ItemControlsOverlay = ({
|
|||||||
itemObj,
|
itemObj,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Control widgets ----
|
|
||||||
|
|
||||||
const ControlWidget = ({
|
|
||||||
control,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
control: Control
|
|
||||||
value: ControlValue
|
|
||||||
onChange: (v: ControlValue) => void
|
|
||||||
}) => {
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
color: 'white',
|
|
||||||
fontSize: 11,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (control.kind === 'toggle') {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(!value)}
|
|
||||||
style={{
|
|
||||||
background: value ? '#4ade80' : '#374151',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: '4px 8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{control.label ?? (value ? 'On' : 'Off')}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (control.kind === 'slider') {
|
|
||||||
return (
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span>
|
|
||||||
{control.label}: {value}
|
|
||||||
{control.unit ? ` ${control.unit}` : ''}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
max={control.max}
|
|
||||||
min={control.min}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
step={control.step}
|
|
||||||
type="range"
|
|
||||||
value={value as number}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (control.kind === 'temperature') {
|
|
||||||
return (
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span>
|
|
||||||
{control.label}: {value}°{control.unit}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
max={control.max}
|
|
||||||
min={control.min}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
step={1}
|
|
||||||
type="range"
|
|
||||||
value={value as number}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user