finished traffic light example + use WebP format for images

This commit is contained in:
Joeri Exelmans 2025-10-31 15:19:32 +01:00
parent ea72f2d40b
commit 73c52c1867
24 changed files with 141 additions and 20 deletions

2
global.d.ts vendored
View file

@ -3,3 +3,5 @@ declare module '*.css';
declare module '*.png'; declare module '*.png';
declare module '*.ttf'; declare module '*.ttf';
declare module '*.wav'; declare module '*.wav';
declare module '*.opus';
declare module '*.webp';

View file

@ -34,6 +34,7 @@ const plants: [string, Plant<any>][] = [
["dummy", dummyPlant], ["dummy", dummyPlant],
["microwave", microwavePlant], ["microwave", microwavePlant],
["digital watch", digitalWatchPlant], ["digital watch", digitalWatchPlant],
["traffic light", trafficLightPlant],
] ]
export type TraceItemError = { export type TraceItemError = {
@ -357,6 +358,7 @@ export function App() {
<option>{plantName}</option> <option>{plantName}</option>
)} )}
</select> </select>
<br/>
{/* Render plant */} {/* Render plant */}
{<plant.render state={plantState} speed={speed} {<plant.render state={plantState} speed={speed}
raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)} raiseUIEvent={e => onRaise("plant.ui."+e.name, e.param)}
@ -428,6 +430,7 @@ function ShowConns({inputEvents, outputEvents}: Conns) {
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import { trafficLightPlant } from "./Plant/TrafficLight/TrafficLight";
function autoDetectConns(ast: Statechart, plant: Plant<any>, setPlantConns: Dispatch<SetStateAction<Conns>>) { function autoDetectConns(ast: Statechart, plant: Plant<any>, setPlantConns: Dispatch<SetStateAction<Conns>>) {
for (const {event: a} of plant.uiEvents) { for (const {event: a} of plant.uiEvents) {
@ -463,18 +466,6 @@ function ConnEditor(ast: Statechart, plant: Plant<any>, plantConns: Conns, setPl
const plantInputs = <>{plant.inputEvents.map(e => <option key={'plant.'+e.event} value={'plant.'+e.event}>plant.{e.event}</option>)}</> const plantInputs = <>{plant.inputEvents.map(e => <option key={'plant.'+e.event} value={'plant.'+e.event}>plant.{e.event}</option>)}</>
const scInputs = <>{ast.inputEvents.map(e => <option key={'sc.'+e.event} value={'sc.'+e.event}>sc.{e.event}</option>)}</>; const scInputs = <>{ast.inputEvents.map(e => <option key={'sc.'+e.event} value={'sc.'+e.event}>sc.{e.event}</option>)}</>;
return <> return <>
{/* Plant UI events can go to SC or to Plant */}
{plant.uiEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-ui-${e.event}`} style={{width:'50%'}}>ui.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-ui-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.ui.'+e.event]?.join('.')}
onChange={domEvent => setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: domEvent.target.value.split('.') as [string,string]}))}>
<option key="none" value=""></option>
{scInputs}
{plantInputs}
</select>
</div>)}
{/* SC output events can go to Plant */} {/* SC output events can go to Plant */}
{[...ast.outputEvents].map(e => <div style={{width:'100%', textAlign:'right'}}> {[...ast.outputEvents].map(e => <div style={{width:'100%', textAlign:'right'}}>
@ -499,6 +490,19 @@ function ConnEditor(ast: Statechart, plant: Plant<any>, plantConns: Conns, setPl
{scInputs} {scInputs}
</select> </select>
</div>)]} </div>)]}
{/* Plant UI events can go to SC or to Plant */}
{plant.uiEvents.map(e => <div style={{width:'100%', textAlign:'right'}}>
<label htmlFor={`select-dst-plant-ui-${e.event}`} style={{width:'50%'}}>ui.{e.event}&nbsp;&nbsp;</label>
<select id={`select-dst-plant-ui-${e.event}`}
style={{width:'50%'}}
value={plantConns['plant.ui.'+e.event]?.join('.')}
onChange={domEvent => setPlantConns(conns => ({...conns, [`plant.ui.${e.event}`]: domEvent.target.value.split('.') as [string,string]}))}>
<option key="none" value=""></option>
{scInputs}
{plantInputs}
</select>
</div>)}
</>; </>;
} }

View file

@ -9,11 +9,11 @@ import fontDigital from "../DigitalWatch/digital-font.ttf";
import sndBell from "./bell.wav"; import sndBell from "./bell.wav";
import sndRunning from "./running.wav"; import sndRunning from "./running.wav";
import { RT_Statechart } from "@/statecharts/runtime_types"; import { RT_Statechart } from "@/statecharts/runtime_types";
import { useEffect } from "react"; import { memo, useEffect } from "react";
import "./Microwave.css"; import "./Microwave.css";
import { useAudioContext } from "../../useAudioContext"; import { useAudioContext } from "../../useAudioContext";
import { makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant"; import { comparePlantRenderProps, makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant";
import { detectConnections } from "@/statecharts/detect_connections"; import { detectConnections } from "@/statecharts/detect_connections";
import { parseStatechart } from "@/statecharts/parser"; import { parseStatechart } from "@/statecharts/parser";
@ -47,7 +47,7 @@ const DOOR_Y0 = 68;
const DOOR_WIDTH = 353; const DOOR_WIDTH = 353;
const DOOR_HEIGHT = 217; const DOOR_HEIGHT = 217;
export function Microwave({state, speed, raiseUIEvent}: PlantRenderProps<RT_Statechart>) { export const Microwave = memo(function Microwave({state, speed, raiseUIEvent}: PlantRenderProps<RT_Statechart>) {
const [playSound, preloadAudio] = useAudioContext(speed); const [playSound, preloadAudio] = useAudioContext(speed);
// preload(imgSmallClosedOff, {as: "image"}); // preload(imgSmallClosedOff, {as: "image"});
@ -106,10 +106,11 @@ export function Microwave({state, speed, raiseUIEvent}: PlantRenderProps<RT_Stat
onMouseDown={() => raiseUIEvent({name: "doorMouseDown"})} onMouseDown={() => raiseUIEvent({name: "doorMouseDown"})}
onMouseUp={() => raiseUIEvent({name: "doorMouseUp"})} onMouseUp={() => raiseUIEvent({name: "doorMouseUp"})}
/> />
<text x={472} y={106} textAnchor="end" fontFamily="digital-font" fontSize={24} fill="lightgreen">{timeDisplay}</text> <text x={472} y={106} textAnchor="end" fontFamily="digital-font" fontSize={24} fill="lightgreen">{timeDisplay}</text>
</svg> </svg>
</>; </>;
} }, comparePlantRenderProps);
const microwavePlantSpec: StatechartPlantSpec = { const microwavePlantSpec: StatechartPlantSpec = {
ast: microwaveAbstractSyntax, ast: microwaveAbstractSyntax,

View file

@ -1,8 +1,9 @@
import { ReactElement } from "react"; import { ReactElement, ReactNode } from "react";
import { Statechart } from "@/statecharts/abstract_syntax"; import { Statechart } from "@/statecharts/abstract_syntax";
import { EventTrigger } from "@/statecharts/label_ast"; import { EventTrigger } from "@/statecharts/label_ast";
import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types"; import { BigStep, RaisedEvent, RT_Statechart } from "@/statecharts/runtime_types";
import { statechartExecution, TimedReactive } from "@/statecharts/timed_reactive"; import { statechartExecution, TimedReactive } from "@/statecharts/timed_reactive";
import { mapsEqual, setsEqual } from "@/util/util";
export type PlantRenderProps<StateType> = { export type PlantRenderProps<StateType> = {
state: StateType, state: StateType,
@ -17,7 +18,7 @@ export type Plant<StateType> = {
outputEvents: EventTrigger[]; outputEvents: EventTrigger[];
execution: TimedReactive<StateType>; execution: TimedReactive<StateType>;
render: (props: PlantRenderProps<StateType>) => ReactElement; render: (props: PlantRenderProps<StateType>) => ReactNode;
} }
// Automatically connect Statechart and Plant inputs/outputs if their event names match. // Automatically connect Statechart and Plant inputs/outputs if their event names match.
@ -55,7 +56,7 @@ export function exposePlantInputs(plant: Plant<any>, plantName: string, tfm = (s
export type StatechartPlantSpec = { export type StatechartPlantSpec = {
uiEvents: EventTrigger[], uiEvents: EventTrigger[],
ast: Statechart, ast: Statechart,
render: (props: PlantRenderProps<RT_Statechart>) => ReactElement, render: (props: PlantRenderProps<RT_Statechart>) => ReactNode,
} }
export function makeStatechartPlant({uiEvents, ast, render}: StatechartPlantSpec): Plant<BigStep> { export function makeStatechartPlant({uiEvents, ast, render}: StatechartPlantSpec): Plant<BigStep> {
@ -67,3 +68,10 @@ export function makeStatechartPlant({uiEvents, ast, render}: StatechartPlantSpec
render, render,
} }
} }
export function comparePlantRenderProps(oldProps: PlantRenderProps<RT_Statechart>, newProps: PlantRenderProps<RT_Statechart>) {
return setsEqual(oldProps.state.mode, newProps.state.mode)
&& oldProps.state.environment === newProps.state.environment // <-- could optimize this further
&& oldProps.speed === newProps.speed
&& oldProps.raiseUIEvent === newProps.raiseUIEvent
}

View file

@ -0,0 +1,86 @@
import fontDigital from "../DigitalWatch/digital-font.ttf";
import imgBackground from "./background.webp";
import imgRedOverlay from "./red-overlay.webp";
import imgYellowOverlay from "./yellow-overlay.webp";
import imgGreenOverlay from "./green-overlay.webp";
import sndAtmosphere from "./atmosphere.opus";
import { preload } from "react-dom";
import trafficLightConcreteSyntax from "./model.json";
import { parseStatechart } from "@/statecharts/parser";
import { ConcreteSyntax } from "@/App/VisualEditor/VisualEditor";
import { detectConnections } from "@/statecharts/detect_connections";
import { comparePlantRenderProps, makeStatechartPlant, PlantRenderProps, StatechartPlantSpec } from "../Plant";
import { RT_Statechart } from "@/statecharts/runtime_types";
import { useAudioContext } from "@/App/useAudioContext";
import { memo, useEffect } from "react";
const [trafficLightAbstractSyntax, trafficLightErrors] = parseStatechart(trafficLightConcreteSyntax as ConcreteSyntax, detectConnections(trafficLightConcreteSyntax as ConcreteSyntax));
if (trafficLightErrors.length > 0) {
console.log({trafficLightErrors});
throw new Error("there were errors parsing traffic light plant model. see console.")
}
export const TrafficLight = memo(function TrafficLight({state, speed, raiseUIEvent}: PlantRenderProps<RT_Statechart>) {
// preload(imgBackground, {as: "image"});
preload(imgRedOverlay, {as: "image"});
preload(imgYellowOverlay, {as: "image"});
preload(imgGreenOverlay, {as: "image"});
const redOn = state.mode.has("85");
const yellowOn = state.mode.has("87");
const greenOn = state.mode.has("89");
const timerGreen = state.mode.has("137");
const timerValue = state.environment.get("t");
const [playURL, preloadAudio] = useAudioContext(speed);
// preloadAudio(sndAtmosphere);
// the traffic light makes sound too:
useEffect(() => {
const stopPlaying = playURL(sndAtmosphere, true);
return () => stopPlaying();
}, []);
return <>
<style>{`
@font-face{
font-family: 'digital-font';
src: url(${fontDigital});
}
image {
transition: opacity 250ms ease;
}
.hidden {
opacity: 0;
}
`}</style>
<svg width={200} height='auto' viewBox="0 0 424 791">
<image xlinkHref={imgBackground} width={424} height={791}/>
<image className={redOn ? "" : "hidden"} xlinkHref={imgRedOverlay} width={424} height={791}/>
<image className={yellowOn ? "" : "hidden"} xlinkHref={imgYellowOverlay} width={424} height={791}/>
<image className={greenOn ? "" : "hidden"} xlinkHref={imgGreenOverlay} width={424} height={791}/>
{timerValue >= 0 && <>
<rect x={300} y={678} width={110} height={80} fill="black" />
<text x={400} y={750} fontFamily="digital-font" fontSize={100} fill={timerGreen ? "#59ae8b" : "#f9172e"} textAnchor="end">{timerValue}</text>
</>}
</svg>
<br/>
<button onClick={() => raiseUIEvent({name: "policeInterrupt"})}>POLICE INTERRUPT</button>
</>;
}, comparePlantRenderProps);
const trafficLightPlantSpec: StatechartPlantSpec = {
ast: trafficLightAbstractSyntax,
render: TrafficLight,
uiEvents: [
{kind: "event", event: "policeInterrupt"},
],
}
export const trafficLightPlant = makeStatechartPlant(trafficLightPlantSpec);

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

View file

@ -322,6 +322,7 @@ export function useMouse(makeCheckPoint: () => void, insertMode: InsertMode, zoo
...state.diamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})), ...state.diamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})), ...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
...state.texts.map(t => ({uid: t.uid, parts: ["text"]})), ...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
...state.history.map(h => ({uid: h.uid, parts: ["history"]})),
] ]
})) }))
} }

View file

@ -66,3 +66,21 @@ export function objectsEqual<T>(a: {[key: string]: T}, b: {[key: string]: T}, cm
return true; return true;
} }
export function mapsEqual<K,V>(a: Map<K,V>, b: Map<K,V>, cmp: (a: V, b: V) => boolean = (a,b)=>a===b) {
if (a===b)
return true;
if (a.size !== b.size)
return false;
for (const [keyA,valA] of a.entries()) {
const valB = b.get(keyA);
if (valB === undefined)
return false;
if (!cmp(valA, valB))
return false;
}
return true;
}