@aaqu/fromcubes-portal-react 0.1.0-alpha.3 → 0.1.0-alpha.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +244 -76
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +101 -0
- package/examples/PixiJS Sprites.json +114 -0
- package/examples/Sensor Portal.json +93 -0
- package/examples/Shared Components.json +68 -0
- package/examples/Three.js Scene.json +118 -0
- package/examples/Utility Hooks.json +94 -0
- package/examples/WebGPU Shader.json +110 -0
- package/nodes/lib/admin-api.js +160 -0
- package/nodes/lib/assets.js +342 -0
- package/nodes/lib/helpers.js +724 -0
- package/nodes/lib/hooks.js +120 -0
- package/nodes/lib/page-builder.js +480 -0
- package/nodes/lib/portal-page-route.js +133 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +80 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +1862 -287
- package/nodes/portal-react.js +1563 -471
- package/package.json +42 -12
- package/examples/sensor-portal-flow.json +0 -71
- package/nodes/vendor/react-19.production.min.js +0 -55
- package/scripts/bundle-react.js +0 -31
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "fc-chart-flow",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "Chart.js Demo",
|
|
6
|
+
"disabled": false
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "cfadc6df4c105c45",
|
|
10
|
+
"type": "comment",
|
|
11
|
+
"z": "fc-chart-flow",
|
|
12
|
+
"name": "Live Chart Demo",
|
|
13
|
+
"info": "Real-time line chart powered by Chart.js, fed via WebSocket.\n\n## What it demonstrates\nA `portal-react` node that bundles `chart.js/auto` (declared in **Libs**) and renders a streaming\nline chart. New data points arrive over WebSocket and are appended client-side.\n\n## How to run\n1. Import **Shared Components**.\n2. Import this flow, deploy (Node-RED auto-installs `chart.js`).\n3. Open `http://<host>:1880/fromcubes/chart`.\n4. Click inject — each click adds a point.\n\n## Expected result\nChart updates smoothly. The Y-axis auto-scales. A header above shows current/min/max.",
|
|
14
|
+
"x": 160,
|
|
15
|
+
"y": 60,
|
|
16
|
+
"wires": []
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "fc-chart-linechart-comp",
|
|
20
|
+
"type": "fc-portal-component",
|
|
21
|
+
"z": "fc-chart-flow",
|
|
22
|
+
"name": "LineChart",
|
|
23
|
+
"compName": "LineChart",
|
|
24
|
+
"compCode": "import ChartJS from 'chart.js/auto';\n\nfunction LineChart({ data = [], labelKey = 'time', datasets = [], title = '', height = 'h-64' }) {\n const canvasRef = useRef(null);\n const chartRef = useRef(null);\n\n useEffect(() => {\n if (!canvasRef.current || data.length === 0) return;\n\n if (chartRef.current) {\n const chart = chartRef.current;\n chart.data.labels = data.map(p => p[labelKey]);\n datasets.forEach((ds, i) => {\n chart.data.datasets[i].data = data.map(p => p[ds.key]);\n });\n chart.update('none');\n return;\n }\n\n\n chartRef.current = new ChartJS(canvasRef.current, {\n type: 'line',\n data: {\n labels: data.map(p => p[labelKey]),\n datasets: datasets.map(ds => ({\n label: ds.label,\n data: data.map(p => p[ds.key]),\n borderColor: ds.color,\n backgroundColor: ds.color + '18',\n tension: 0.35,\n fill: true,\n pointRadius: 2,\n borderWidth: 2\n }))\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n animation: false,\n plugins: {\n legend: { labels: { color: '#a1a1aa', usePointStyle: true, pointStyle: 'circle', padding: 16 } }\n },\n scales: {\n x: { ticks: { color: '#52525b', font: { size: 10 } }, grid: { color: '#27272a' } },\n y: { ticks: { color: '#52525b', font: { size: 10 } }, grid: { color: '#27272a' } }\n }\n }\n });\n\n return () => {\n if (chartRef.current) {\n chartRef.current.destroy();\n chartRef.current = null;\n }\n };\n }, [data, datasets]);\n\n return (\n <div className=\"bg-zinc-900/50 rounded-xl border border-zinc-800/50 p-5\">\n {title && <h3 className=\"text-sm font-medium text-zinc-300 mb-3\">{title}</h3>}\n <div className={height}>\n <canvas ref={canvasRef} />\n </div>\n </div>\n );\n}",
|
|
25
|
+
"x": 380,
|
|
26
|
+
"y": 80,
|
|
27
|
+
"wires": []
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "fc-chart-inject",
|
|
31
|
+
"type": "inject",
|
|
32
|
+
"z": "fc-chart-flow",
|
|
33
|
+
"name": "Sensor tick",
|
|
34
|
+
"props": [
|
|
35
|
+
{
|
|
36
|
+
"p": "payload"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"repeat": "2",
|
|
40
|
+
"payload": "{}",
|
|
41
|
+
"payloadType": "json",
|
|
42
|
+
"x": 160,
|
|
43
|
+
"y": 160,
|
|
44
|
+
"wires": [
|
|
45
|
+
[
|
|
46
|
+
"fc-chart-func"
|
|
47
|
+
]
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "fc-chart-func",
|
|
52
|
+
"type": "function",
|
|
53
|
+
"z": "fc-chart-flow",
|
|
54
|
+
"name": "Random data point",
|
|
55
|
+
"func": "var history = flow.get('chartHistory') || [];\nvar now = new Date();\nhistory.push({\n time: now.toLocaleTimeString(),\n temp: +(18 + Math.random() * 12).toFixed(1),\n humidity: Math.round(35 + Math.random() * 40),\n cpu: Math.round(15 + Math.random() * 60),\n memory: Math.round(40 + Math.random() * 45)\n});\nif (history.length > 20) history.shift();\nflow.set('chartHistory', history);\nmsg.payload = history;\nreturn msg;",
|
|
56
|
+
"outputs": 1,
|
|
57
|
+
"x": 380,
|
|
58
|
+
"y": 160,
|
|
59
|
+
"wires": [
|
|
60
|
+
[
|
|
61
|
+
"fc-chart-portal"
|
|
62
|
+
]
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "fc-chart-portal",
|
|
67
|
+
"type": "portal-react",
|
|
68
|
+
"z": "fc-chart-flow",
|
|
69
|
+
"name": "Chart Portal",
|
|
70
|
+
"subPath": "chart",
|
|
71
|
+
"pageTitle": "fromcubes charts",
|
|
72
|
+
"customHead": "",
|
|
73
|
+
"portalAuth": false,
|
|
74
|
+
"showWsStatus": false,
|
|
75
|
+
"libs": [
|
|
76
|
+
{
|
|
77
|
+
"module": "chart.js/auto",
|
|
78
|
+
"var": "Chart"
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
"componentCode": "function App() {\n const { data } = useNodeRed();\n const [tab, setTab] = useState(0);\n const points = data || [];\n\n const tabs = [\n { label: 'Overview', icon: '\\u25a6' },\n { label: 'Environment', icon: '\\u25cb' },\n { label: 'System', icon: '\\u25a1' }\n ];\n\n return (\n <Page>\n <div className=\"max-w-5xl mx-auto px-6 py-8\">\n\n <Header title=\"fromcubes charts\" subtitle={points.length + ' data points \\u2022 live'} />\n\n <div className=\"flex gap-1 mb-6 bg-zinc-900/50 rounded-lg p-1 w-fit\">\n {tabs.map((t, i) => (\n <button\n key={i}\n onClick={() => setTab(i)}\n className={`px-4 py-2 rounded-md text-sm transition-all ${\n tab === i\n ? 'bg-zinc-800 text-zinc-100 shadow-sm'\n : 'text-zinc-500 hover:text-zinc-300'\n }`}\n >{t.icon} {t.label}</button>\n ))}\n </div>\n\n {tab === 0 && (\n <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-4\">\n <LineChart\n data={points}\n title=\"Temperature\"\n datasets={[{ key: 'temp', label: 'Temp (\\u00b0C)', color: '#22d3ee' }]}\n />\n <LineChart\n data={points}\n title=\"Humidity\"\n datasets={[{ key: 'humidity', label: 'Humidity (%)', color: '#a78bfa' }]}\n />\n <LineChart\n data={points}\n title=\"CPU Usage\"\n datasets={[{ key: 'cpu', label: 'CPU (%)', color: '#f97316' }]}\n />\n <LineChart\n data={points}\n title=\"Memory\"\n datasets={[{ key: 'memory', label: 'Memory (%)', color: '#34d399' }]}\n />\n </div>\n )}\n\n {tab === 1 && (\n <div className=\"space-y-4\">\n <LineChart\n data={points}\n title=\"Temperature & Humidity\"\n height=\"h-80\"\n datasets={[\n { key: 'temp', label: 'Temp (\\u00b0C)', color: '#22d3ee' },\n { key: 'humidity', label: 'Humidity (%)', color: '#a78bfa' }\n ]}\n />\n <div className=\"grid grid-cols-2 gap-4\">\n <div className=\"bg-zinc-900/50 rounded-xl border border-zinc-800/50 p-5\">\n <div className=\"text-xs text-zinc-500 uppercase tracking-wider\">Current Temp</div>\n <div className=\"text-3xl font-bold text-cyan-400 mt-2\">\n {points.length > 0 ? points[points.length - 1].temp : '--'}\n <span className=\"text-sm font-normal text-zinc-500 ml-1\">\\u00b0C</span>\n </div>\n </div>\n <div className=\"bg-zinc-900/50 rounded-xl border border-zinc-800/50 p-5\">\n <div className=\"text-xs text-zinc-500 uppercase tracking-wider\">Current Humidity</div>\n <div className=\"text-3xl font-bold text-violet-400 mt-2\">\n {points.length > 0 ? points[points.length - 1].humidity : '--'}\n <span className=\"text-sm font-normal text-zinc-500 ml-1\">%</span>\n </div>\n </div>\n </div>\n </div>\n )}\n\n {tab === 2 && (\n <div className=\"space-y-4\">\n <LineChart\n data={points}\n title=\"CPU & Memory\"\n height=\"h-80\"\n datasets={[\n { key: 'cpu', label: 'CPU (%)', color: '#f97316' },\n { key: 'memory', label: 'Memory (%)', color: '#34d399' }\n ]}\n />\n <div className=\"grid grid-cols-2 gap-4\">\n <div className=\"bg-zinc-900/50 rounded-xl border border-zinc-800/50 p-5\">\n <div className=\"text-xs text-zinc-500 uppercase tracking-wider\">Current CPU</div>\n <div className=\"text-3xl font-bold text-orange-400 mt-2\">\n {points.length > 0 ? points[points.length - 1].cpu : '--'}\n <span className=\"text-sm font-normal text-zinc-500 ml-1\">%</span>\n </div>\n </div>\n <div className=\"bg-zinc-900/50 rounded-xl border border-zinc-800/50 p-5\">\n <div className=\"text-xs text-zinc-500 uppercase tracking-wider\">Current Memory</div>\n <div className=\"text-3xl font-bold text-emerald-400 mt-2\">\n {points.length > 0 ? points[points.length - 1].memory : '--'}\n <span className=\"text-sm font-normal text-zinc-500 ml-1\">%</span>\n </div>\n </div>\n </div>\n </div>\n )}\n\n </div>\n </Page>\n );\n}",
|
|
82
|
+
"x": 600,
|
|
83
|
+
"y": 160,
|
|
84
|
+
"wires": [
|
|
85
|
+
[
|
|
86
|
+
"fc-chart-debug"
|
|
87
|
+
]
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"id": "fc-chart-debug",
|
|
92
|
+
"type": "debug",
|
|
93
|
+
"z": "fc-chart-flow",
|
|
94
|
+
"name": "Chart output",
|
|
95
|
+
"active": true,
|
|
96
|
+
"tosidebar": true,
|
|
97
|
+
"x": 790,
|
|
98
|
+
"y": 160,
|
|
99
|
+
"wires": []
|
|
100
|
+
}
|
|
101
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "fc-pixi-flow",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "PixiJS Demo",
|
|
6
|
+
"disabled": false
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "c019a0795a04bcac",
|
|
10
|
+
"type": "comment",
|
|
11
|
+
"z": "fc-pixi-flow",
|
|
12
|
+
"name": "PixiJS Sprites Demo",
|
|
13
|
+
"info": "Click-spawn bunny sprites using PixiJS + @pixi/react.\n\n## What it demonstrates\nPixiJS via the React renderer. Click events from the canvas flow back through the portal's output\nwire as `{bunnies: N}`.\n\n## How to run\n1. Import this flow, deploy (auto-installs `pixi.js`, `@pixi/react`).\n2. Open `http://<host>:1880/fromcubes/pixi`.\n3. Click anywhere on the canvas.\n\n## Expected result\nBunny sprite appears at click position. Count message arrives in **debug** sidebar.",
|
|
14
|
+
"x": 160,
|
|
15
|
+
"y": 60,
|
|
16
|
+
"wires": []
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "fc-pixi-comp-clicksprite",
|
|
20
|
+
"type": "fc-portal-component",
|
|
21
|
+
"z": "fc-pixi-flow",
|
|
22
|
+
"compName": "ClickableSprite",
|
|
23
|
+
"compCode": "import { Assets, Texture } from 'pixi.js';\nimport { useTick } from '@pixi/react';\n\nfunction ClickableSprite({ id, x, y, tint, onClick }) {\n const ref = useRef(null);\n const [texture, setTexture] = useState(Texture.EMPTY);\n const [hovered, setHovered] = useState(false);\n const clicks = useRef(0);\n const time = useRef(Math.random() * Math.PI * 2);\n\n useEffect(() => {\n if (texture === Texture.EMPTY) {\n Assets.load('https://pixijs.com/assets/bunny.png').then(setTexture);\n }\n }, [texture]);\n\n useTick((t) => {\n if (!ref.current) return;\n time.current += t.deltaTime * 0.03;\n ref.current.rotation = Math.sin(time.current) * 0.15;\n });\n\n return (\n <pixiSprite\n ref={ref}\n texture={texture}\n anchor={0.5}\n x={x}\n y={y}\n tint={tint}\n scale={hovered ? 2.2 : 1.8}\n eventMode=\"static\"\n cursor=\"pointer\"\n onPointerOver={() => setHovered(true)}\n onPointerOut={() => setHovered(false)}\n onClick={() => {\n clicks.current += 1;\n onClick({ id, x, y, clicks: clicks.current });\n }}\n />\n );\n}",
|
|
24
|
+
"x": 160,
|
|
25
|
+
"y": 300,
|
|
26
|
+
"wires": []
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "fc-pixi-comp-statusbar",
|
|
30
|
+
"type": "fc-portal-component",
|
|
31
|
+
"z": "fc-pixi-flow",
|
|
32
|
+
"compName": "StatusBar",
|
|
33
|
+
"compCode": "function StatusBar({ spriteCount, totalClicks, lastClicked }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Sprites\">\n <span className=\"text-sm font-mono text-cyan-400\">{spriteCount}</span>\n </Stat>\n <Stat label=\"Total clicks\">\n <span className=\"text-sm font-mono text-emerald-400\">{totalClicks}</span>\n </Stat>\n <Stat label=\"Last clicked\">\n <span className=\"text-sm font-mono text-zinc-300\">{lastClicked || '\\u2014'}</span>\n </Stat>\n </div>\n );\n}",
|
|
34
|
+
"x": 360,
|
|
35
|
+
"y": 300,
|
|
36
|
+
"wires": []
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "fc-pixi-inject",
|
|
40
|
+
"type": "inject",
|
|
41
|
+
"z": "fc-pixi-flow",
|
|
42
|
+
"name": "Tick",
|
|
43
|
+
"props": [
|
|
44
|
+
{
|
|
45
|
+
"p": "payload"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"repeat": "5",
|
|
49
|
+
"payload": "{}",
|
|
50
|
+
"payloadType": "json",
|
|
51
|
+
"x": 160,
|
|
52
|
+
"y": 160,
|
|
53
|
+
"wires": [
|
|
54
|
+
[
|
|
55
|
+
"fc-pixi-func"
|
|
56
|
+
]
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "fc-pixi-func",
|
|
61
|
+
"type": "function",
|
|
62
|
+
"z": "fc-pixi-flow",
|
|
63
|
+
"name": "Random tint",
|
|
64
|
+
"func": "const tints = [0x22d3ee, 0xa78bfa, 0x34d399, 0xfbbf24, 0xf87171, 0x60a5fa, 0xfb923c, 0xe879f9];\nmsg.payload = {\n tint: tints[Math.floor(Math.random() * tints.length)]\n};\nreturn msg;",
|
|
65
|
+
"outputs": 1,
|
|
66
|
+
"x": 360,
|
|
67
|
+
"y": 160,
|
|
68
|
+
"wires": [
|
|
69
|
+
[
|
|
70
|
+
"fc-pixi-portal"
|
|
71
|
+
]
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "fc-pixi-portal",
|
|
76
|
+
"type": "portal-react",
|
|
77
|
+
"z": "fc-pixi-flow",
|
|
78
|
+
"name": "Pixi Portal",
|
|
79
|
+
"subPath": "pixi",
|
|
80
|
+
"pageTitle": "fromcubes Pixi",
|
|
81
|
+
"customHead": "",
|
|
82
|
+
"portalAuth": false,
|
|
83
|
+
"showWsStatus": false,
|
|
84
|
+
"libs": [
|
|
85
|
+
{
|
|
86
|
+
"module": "pixi.js",
|
|
87
|
+
"var": "PIXI"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"module": "@pixi/react",
|
|
91
|
+
"var": "PixiReact"
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
"componentCode": "import { Sprite } from 'pixi.js';\nimport { Application, extend } from '@pixi/react';\nextend({ Sprite });\n\nfunction App() {\n const { data, send } = useNodeRed();\n const [sprites, setSprites] = useState([]);\n const [totalClicks, setTotalClicks] = useState(0);\n const [lastClicked, setLastClicked] = useState(null);\n const idRef = useRef(0);\n\n useEffect(() => {\n if (!data) return;\n setSprites(prev => {\n const next = [...prev, {\n id: idRef.current++,\n x: 40 + Math.random() * 520,\n y: 40 + Math.random() * 320,\n tint: data.tint || 0x22d3ee\n }];\n return next.length > 12 ? next.slice(-12) : next;\n });\n }, [data]);\n\n const handleClick = useCallback((info) => {\n setTotalClicks(c => c + 1);\n setLastClicked('#' + info.id);\n send({\n action: 'clicked',\n spriteId: info.id,\n x: Math.round(info.x),\n y: Math.round(info.y),\n spriteClicks: info.clicks,\n timestamp: Date.now()\n }, 'pixi/click');\n }, [send]);\n\n return (\n <Page>\n <Header title=\"fromcubes Pixi\" subtitle=\"click the bunnies\" />\n <div className=\"flex-1 flex items-center justify-center p-6\">\n <div className=\"rounded-xl overflow-hidden border border-zinc-800\">\n <Application width={600} height={400} background={0x18181b} antialias>\n {sprites.map(s => (\n <ClickableSprite key={s.id} {...s} onClick={handleClick} />\n ))}\n </Application>\n </div>\n </div>\n <StatusBar spriteCount={sprites.length} totalClicks={totalClicks} lastClicked={lastClicked} />\n </Page>\n );\n}",
|
|
95
|
+
"x": 560,
|
|
96
|
+
"y": 160,
|
|
97
|
+
"wires": [
|
|
98
|
+
[
|
|
99
|
+
"fc-pixi-debug"
|
|
100
|
+
]
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"id": "fc-pixi-debug",
|
|
105
|
+
"type": "debug",
|
|
106
|
+
"z": "fc-pixi-flow",
|
|
107
|
+
"name": "Pixi output",
|
|
108
|
+
"active": true,
|
|
109
|
+
"tosidebar": true,
|
|
110
|
+
"x": 750,
|
|
111
|
+
"y": 160,
|
|
112
|
+
"wires": []
|
|
113
|
+
}
|
|
114
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "fc-demo-flow",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "Sensor Portal",
|
|
6
|
+
"disabled": false
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "ba6b163996fca9c1",
|
|
10
|
+
"type": "comment",
|
|
11
|
+
"z": "fc-demo-flow",
|
|
12
|
+
"name": "Sensor Portal Demo",
|
|
13
|
+
"info": "Live sensor gauge driven by msg.payload over WebSocket.\n\n## What it demonstrates\nA `portal-react` node that renders a circular gauge whose value reflects the last `msg.payload`\nbroadcast on the input wire.\n\n## How to run\n1. Import **Shared Components** first (provides Page, Header, Stat).\n2. Import this flow, deploy.\n3. Open `http://<host>:1880/fromcubes/sensors` in a browser.\n4. Click the **inject** node to push a payload.\n\n## Expected result\nThe gauge animates to the injected value. Multiple browser tabs stay in sync (broadcast).",
|
|
14
|
+
"x": 160,
|
|
15
|
+
"y": 60,
|
|
16
|
+
"wires": []
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "fc-inject",
|
|
20
|
+
"type": "inject",
|
|
21
|
+
"z": "fc-demo-flow",
|
|
22
|
+
"name": "Sensor tick",
|
|
23
|
+
"props": [
|
|
24
|
+
{
|
|
25
|
+
"p": "payload"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"repeat": "2",
|
|
29
|
+
"payload": "{}",
|
|
30
|
+
"payloadType": "json",
|
|
31
|
+
"x": 160,
|
|
32
|
+
"y": 160,
|
|
33
|
+
"wires": [
|
|
34
|
+
[
|
|
35
|
+
"fc-rand"
|
|
36
|
+
]
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "fc-rand",
|
|
41
|
+
"type": "function",
|
|
42
|
+
"z": "fc-demo-flow",
|
|
43
|
+
"name": "Random sensors",
|
|
44
|
+
"func": "msg.payload = {\n temp: +(18 + Math.random() * 12).toFixed(1),\n humidity: Math.round(35 + Math.random() * 40),\n pressure: Math.round(995 + Math.random() * 35),\n ts: Date.now()\n};\nreturn msg;",
|
|
45
|
+
"outputs": 1,
|
|
46
|
+
"x": 360,
|
|
47
|
+
"y": 160,
|
|
48
|
+
"wires": [
|
|
49
|
+
[
|
|
50
|
+
"fc-portal"
|
|
51
|
+
]
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "fc-gauge-comp",
|
|
56
|
+
"type": "fc-portal-component",
|
|
57
|
+
"z": "fc-demo-flow",
|
|
58
|
+
"name": "Gauge",
|
|
59
|
+
"compName": "Gauge",
|
|
60
|
+
"compCode": "function Gauge({ value = 0, min = 0, max = 100, label = '', unit = '' }) {\n const pct = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));\n const color = pct > 80 ? 'text-red-400' : pct > 50 ? 'text-amber-400' : 'text-green-400';\n const bg = pct > 80 ? 'from-red-500' : pct > 50 ? 'from-amber-500' : 'from-green-500';\n return (\n <div className=\"flex flex-col items-center p-4\">\n <div className=\"text-xs text-zinc-500 uppercase tracking-wider mb-2\">{label}</div>\n <div className=\"relative w-28 h-28 rounded-full bg-zinc-900 flex items-center justify-center\">\n <svg className=\"absolute inset-0 w-full h-full -rotate-90\" viewBox=\"0 0 100 100\">\n <circle cx=\"50\" cy=\"50\" r=\"42\" fill=\"none\" strokeWidth=\"6\" className=\"stroke-zinc-800\" />\n <circle cx=\"50\" cy=\"50\" r=\"42\" fill=\"none\" strokeWidth=\"6\"\n className={`stroke-current ${color}`}\n strokeDasharray={`${pct * 2.64} 264`}\n strokeLinecap=\"round\" />\n </svg>\n <div className={`text-2xl font-bold ${color}`}>\n {Math.round(value)}<span className=\"text-sm ml-0.5\">{unit}</span>\n </div>\n </div>\n </div>\n );\n}",
|
|
61
|
+
"x": 160,
|
|
62
|
+
"y": 280,
|
|
63
|
+
"wires": []
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "fc-portal",
|
|
67
|
+
"type": "portal-react",
|
|
68
|
+
"z": "fc-demo-flow",
|
|
69
|
+
"name": "Sensor Portal",
|
|
70
|
+
"subPath": "sensors",
|
|
71
|
+
"pageTitle": "Sensors",
|
|
72
|
+
"customHead": "",
|
|
73
|
+
"componentCode": "function App() {\n const { data, send } = useNodeRed();\n const s = data || { temp: 0, humidity: 0, pressure: 0 };\n\n return (\n <Page>\n <Header title=\"Sensor Portal\" subtitle=\"live sensor data\" />\n <div className=\"flex-1 flex flex-col items-center justify-center p-8\">\n <div className=\"flex gap-10 flex-wrap justify-center\">\n <Gauge value={s.temp} min={-10} max={50} label=\"Temperature\" unit=\"°C\" />\n <Gauge value={s.humidity} min={0} max={100} label=\"Humidity\" unit=\"%\" />\n <Gauge value={s.pressure} min={950} max={1050} label=\"Pressure\" unit=\"hPa\" />\n </div>\n <div className=\"mt-10 flex gap-3 items-center\">\n <Button onClick={() => send({ action: 'refresh' })}>Refresh</Button>\n <ValueBadge label=\"Last\" value={s.ts ? new Date(s.ts).toLocaleTimeString() : null} />\n </div>\n </div>\n </Page>\n );\n}",
|
|
74
|
+
"x": 560,
|
|
75
|
+
"y": 160,
|
|
76
|
+
"wires": [
|
|
77
|
+
[
|
|
78
|
+
"fc-debug"
|
|
79
|
+
]
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "fc-debug",
|
|
84
|
+
"type": "debug",
|
|
85
|
+
"z": "fc-demo-flow",
|
|
86
|
+
"name": "Portal output",
|
|
87
|
+
"active": true,
|
|
88
|
+
"tosidebar": true,
|
|
89
|
+
"x": 750,
|
|
90
|
+
"y": 160,
|
|
91
|
+
"wires": []
|
|
92
|
+
}
|
|
93
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "fc-shared-flow",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "Shared Components",
|
|
6
|
+
"disabled": false
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "bda6e8d95820e1fb",
|
|
10
|
+
"type": "comment",
|
|
11
|
+
"z": "fc-shared-flow",
|
|
12
|
+
"name": "Shared Components Library",
|
|
13
|
+
"info": "Reusable UI components used by other portal-react examples.\n\n## What it demonstrates\nA set of `fc-portal-component` nodes (Page, Header, Stat, Button, ValueBadge) that any portal can\nreference. No portal node is included — these components are the building blocks for examples 002+.\n\n## How to run\n1. Import this flow first.\n2. Deploy.\n3. Import any other example (Sensor Portal, Live Chart, …) — those flows reference these components by name.\n\n## Expected result\nFive `fc-portal-component` nodes appear in the workspace under the **fromcubes** category.\nTheir identifiers (`Page`, `Header`, `Stat`, `Button`, `ValueBadge`) become available globally for any\nportal-react node's JSX.",
|
|
14
|
+
"x": 160,
|
|
15
|
+
"y": 60,
|
|
16
|
+
"wires": []
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "fc-shared-comp-header",
|
|
20
|
+
"type": "fc-portal-component",
|
|
21
|
+
"z": "fc-shared-flow",
|
|
22
|
+
"compName": "Header",
|
|
23
|
+
"compCode": "function Header({ title = 'fromcubes', subtitle = '', children }) {\n return (\n <div className=\"flex items-center justify-between px-6 py-4\">\n <div>\n <h1 className=\"text-xl font-semibold text-zinc-100 tracking-tight\">{title}</h1>\n {subtitle && <p className=\"text-xs text-zinc-600 mt-0.5\">{subtitle}</p>}\n </div>\n <div className=\"flex items-center gap-2\">\n {children || (\n <>\n <span className=\"inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse\" />\n <span className=\"text-xs text-zinc-500\">streaming</span>\n </>\n )}\n </div>\n </div>\n );\n}",
|
|
24
|
+
"x": 160,
|
|
25
|
+
"y": 160,
|
|
26
|
+
"wires": []
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "fc-shared-comp-stat",
|
|
30
|
+
"type": "fc-portal-component",
|
|
31
|
+
"z": "fc-shared-flow",
|
|
32
|
+
"compName": "Stat",
|
|
33
|
+
"compCode": "function Stat({ label, children }) {\n return (\n <div className=\"flex items-center gap-3\">\n <span className=\"text-xs text-zinc-500\">{label}</span>\n {children}\n </div>\n );\n}",
|
|
34
|
+
"x": 360,
|
|
35
|
+
"y": 160,
|
|
36
|
+
"wires": []
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "fc-shared-comp-button",
|
|
40
|
+
"type": "fc-portal-component",
|
|
41
|
+
"z": "fc-shared-flow",
|
|
42
|
+
"compName": "Button",
|
|
43
|
+
"compCode": "function Button({ onClick, children, variant = 'primary', className = '' }) {\n const base = 'px-4 py-2 rounded-lg font-medium transition-colors';\n const variants = {\n primary: 'bg-blue-600 text-white hover:bg-blue-500',\n secondary: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600',\n danger: 'bg-red-600 text-white hover:bg-red-500'\n };\n return (\n <button\n className={`${base} ${variants[variant] || variants.primary} ${className}`}\n onClick={onClick}\n >{children}</button>\n );\n}",
|
|
44
|
+
"x": 560,
|
|
45
|
+
"y": 160,
|
|
46
|
+
"wires": []
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "fc-shared-comp-valuebadge",
|
|
50
|
+
"type": "fc-portal-component",
|
|
51
|
+
"z": "fc-shared-flow",
|
|
52
|
+
"compName": "ValueBadge",
|
|
53
|
+
"compCode": "function ValueBadge({ label, value, className = '' }) {\n if (value == null) return null;\n return (\n <span className={`text-zinc-600 text-xs ${className}`}>\n {label && <>{label}: </>}{value}\n </span>\n );\n}",
|
|
54
|
+
"x": 760,
|
|
55
|
+
"y": 160,
|
|
56
|
+
"wires": []
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "fc-shared-comp-page",
|
|
60
|
+
"type": "fc-portal-component",
|
|
61
|
+
"z": "fc-shared-flow",
|
|
62
|
+
"compName": "Page",
|
|
63
|
+
"compCode": "function Page({ children, className = '' }) {\n return (\n <div className={`min-h-screen bg-zinc-950 text-zinc-100 flex flex-col ${className}`}>\n {children}\n </div>\n );\n}",
|
|
64
|
+
"x": 160,
|
|
65
|
+
"y": 240,
|
|
66
|
+
"wires": []
|
|
67
|
+
}
|
|
68
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "fc-three-flow",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "Three.js Demo",
|
|
6
|
+
"disabled": false
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "7ad6c130eab72734",
|
|
10
|
+
"type": "comment",
|
|
11
|
+
"z": "fc-three-flow",
|
|
12
|
+
"name": "Three.js Scene Demo",
|
|
13
|
+
"info": "Rotating 3D cube rendered with Three.js + @react-three/fiber.\n\n## What it demonstrates\nMultiple library deps declared together: `three`, `@react-three/fiber`, `@react-three/drei`.\nShows that the React `alias` in esbuild keeps a single React instance across all peer deps\n(no \"Invalid hook call\" errors).\n\n## How to run\n1. Import this flow, deploy.\n2. Open `http://<host>:1880/fromcubes/threejs`.\n\n## Expected result\nA textured cube rotates in a WebGL canvas. Orbit controls work with mouse drag.",
|
|
14
|
+
"x": 160,
|
|
15
|
+
"y": 60,
|
|
16
|
+
"wires": []
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "fc-three-comp-box",
|
|
20
|
+
"type": "fc-portal-component",
|
|
21
|
+
"z": "fc-three-flow",
|
|
22
|
+
"compName": "Box",
|
|
23
|
+
"compCode": "import { useFrame } from '@react-three/fiber';\n\nfunction Box({ position, color = 'orange', onToggle }) {\n const ref = useRef();\n const [hovered, hover] = useState(false);\n const [clicked, click] = useState(false);\n\n useFrame((state, delta) => (ref.current.rotation.x += delta));\n\n return (\n <mesh\n position={position}\n ref={ref}\n scale={clicked ? 1.5 : 1}\n onClick={() => {\n click(!clicked);\n if (onToggle) onToggle(!clicked);\n }}\n onPointerOver={(e) => (e.stopPropagation(), hover(true))}\n onPointerOut={() => hover(false)}\n >\n <boxGeometry args={[1, 1, 1]} />\n <meshStandardMaterial color={hovered ? 'hotpink' : color} />\n </mesh>\n );\n}",
|
|
24
|
+
"x": 160,
|
|
25
|
+
"y": 300,
|
|
26
|
+
"wires": []
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "fc-three-comp-statusbar",
|
|
30
|
+
"type": "fc-portal-component",
|
|
31
|
+
"z": "fc-three-flow",
|
|
32
|
+
"compName": "StatusBar",
|
|
33
|
+
"compCode": "function StatusBar({ leftColor, rightColor, clickCount }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Left box\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{ backgroundColor: leftColor }} />\n </Stat>\n <Stat label=\"Right box\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{ backgroundColor: rightColor }} />\n </Stat>\n <Stat label=\"Clicks\">\n <span className=\"text-sm font-mono text-cyan-400\">{clickCount}</span>\n </Stat>\n </div>\n );\n}",
|
|
34
|
+
"x": 360,
|
|
35
|
+
"y": 300,
|
|
36
|
+
"wires": []
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "fc-three-inject",
|
|
40
|
+
"type": "inject",
|
|
41
|
+
"z": "fc-three-flow",
|
|
42
|
+
"name": "Tick",
|
|
43
|
+
"props": [
|
|
44
|
+
{
|
|
45
|
+
"p": "payload"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"repeat": "3",
|
|
49
|
+
"payload": "{}",
|
|
50
|
+
"payloadType": "json",
|
|
51
|
+
"x": 160,
|
|
52
|
+
"y": 160,
|
|
53
|
+
"wires": [
|
|
54
|
+
[
|
|
55
|
+
"fc-three-func"
|
|
56
|
+
]
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "fc-three-func",
|
|
61
|
+
"type": "function",
|
|
62
|
+
"z": "fc-three-flow",
|
|
63
|
+
"name": "Random colors",
|
|
64
|
+
"func": "const rnd = () => '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0');\nmsg.payload = {\n leftColor: rnd(),\n rightColor: rnd()\n};\nreturn msg;",
|
|
65
|
+
"outputs": 1,
|
|
66
|
+
"x": 360,
|
|
67
|
+
"y": 160,
|
|
68
|
+
"wires": [
|
|
69
|
+
[
|
|
70
|
+
"fc-three-portal"
|
|
71
|
+
]
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "fc-three-portal",
|
|
76
|
+
"type": "portal-react",
|
|
77
|
+
"z": "fc-three-flow",
|
|
78
|
+
"name": "3D Portal",
|
|
79
|
+
"subPath": "threejs",
|
|
80
|
+
"pageTitle": "fromcubes 3D",
|
|
81
|
+
"customHead": "",
|
|
82
|
+
"portalAuth": false,
|
|
83
|
+
"showWsStatus": false,
|
|
84
|
+
"libs": [
|
|
85
|
+
{
|
|
86
|
+
"module": "three",
|
|
87
|
+
"var": "THREE"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"module": "@react-three/fiber",
|
|
91
|
+
"var": "R3F"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"module": "@react-three/drei",
|
|
95
|
+
"var": "Drei"
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
"componentCode": "import { Canvas } from '@react-three/fiber';\nimport { OrbitControls } from '@react-three/drei';\n\nfunction App() {\n const { data, send } = useNodeRed();\n const colors = data || { leftColor: 'orange', rightColor: 'cyan' };\n const [clickCount, setClickCount] = useState(0);\n\n const handleToggle = useCallback((clicked) => {\n setClickCount(prev => prev + 1);\n send({ action: clicked ? 'clicked' : 'unclicked', clicks: clickCount + 1 }, '3d/interact');\n }, [send, clickCount]);\n\n return (\n <div className=\"h-screen bg-zinc-950 text-zinc-100 flex flex-col\">\n <Header title=\"fromcubes 3D\" subtitle=\"React Three Fiber + live Node-RED data\" />\n <div className=\"flex-1 overflow-hidden\">\n <Canvas style={{ width: '100%', height: '100%' }}>\n <ambientLight intensity={Math.PI / 2} />\n <spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} decay={0} intensity={Math.PI} />\n <pointLight position={[-10, -10, -10]} decay={0} intensity={Math.PI} />\n <Box position={[-1.2, 0, 0]} color={colors.leftColor} onToggle={handleToggle} />\n <Box position={[1.2, 0, 0]} color={colors.rightColor} onToggle={handleToggle} />\n <OrbitControls />\n </Canvas>\n </div>\n <StatusBar leftColor={colors.leftColor} rightColor={colors.rightColor} clickCount={clickCount} />\n </div>\n );\n}",
|
|
99
|
+
"x": 560,
|
|
100
|
+
"y": 160,
|
|
101
|
+
"wires": [
|
|
102
|
+
[
|
|
103
|
+
"fc-three-debug"
|
|
104
|
+
]
|
|
105
|
+
]
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"id": "fc-three-debug",
|
|
109
|
+
"type": "debug",
|
|
110
|
+
"z": "fc-three-flow",
|
|
111
|
+
"name": "3D output",
|
|
112
|
+
"active": true,
|
|
113
|
+
"tosidebar": true,
|
|
114
|
+
"x": 750,
|
|
115
|
+
"y": 160,
|
|
116
|
+
"wires": []
|
|
117
|
+
}
|
|
118
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "fc-util-flow",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "Utility: useDebounce",
|
|
6
|
+
"disabled": false
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "5e0a035124844a91",
|
|
10
|
+
"type": "comment",
|
|
11
|
+
"z": "fc-util-flow",
|
|
12
|
+
"name": "Utility Hooks Demo",
|
|
13
|
+
"info": "Custom React hook + helper exposed via `fc-portal-utility` node.\n\n## What it demonstrates\nA single `fc-portal-utility` node declaring `useDebounce()` (custom hook) and `clamp()` (helper).\nThe companion `Bar` component and portal-react JSX consume them — illustrates how utility code is\ninjected at the top level of the bundle (no IIFE wrapper) so any component can call its symbols.\n\n## How to run\n1. Import this flow, deploy.\n2. Open `http://<host>:1880/fromcubes/debounce`.\n3. Click inject repeatedly.\n\n## Expected result\nRapid injects coalesce — the bar only updates 300 ms after the last burst. Values clamp to [0, 100].",
|
|
14
|
+
"x": 160,
|
|
15
|
+
"y": 60,
|
|
16
|
+
"wires": []
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "fc-util-tick",
|
|
20
|
+
"type": "inject",
|
|
21
|
+
"z": "fc-util-flow",
|
|
22
|
+
"name": "Fast tick (10Hz)",
|
|
23
|
+
"props": [
|
|
24
|
+
{
|
|
25
|
+
"p": "payload"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"repeat": "0.1",
|
|
29
|
+
"payload": "",
|
|
30
|
+
"payloadType": "date",
|
|
31
|
+
"x": 160,
|
|
32
|
+
"y": 160,
|
|
33
|
+
"wires": [
|
|
34
|
+
[
|
|
35
|
+
"fc-util-noisy"
|
|
36
|
+
]
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "fc-util-noisy",
|
|
41
|
+
"type": "function",
|
|
42
|
+
"z": "fc-util-flow",
|
|
43
|
+
"name": "Noisy signal",
|
|
44
|
+
"func": "// Random walk between 0..120 with bursts of noise — exactly the sort of\n// fast-changing input where a debounced display is much more readable\n// than the raw value.\nconst prev = context.get('v') ?? 50;\nconst drift = (Math.random() - 0.5) * 8;\nconst noise = Math.random() < 0.15 ? (Math.random() - 0.5) * 30 : 0;\nconst v = Math.max(0, Math.min(120, prev + drift + noise));\ncontext.set('v', v);\nmsg.payload = { value: +v.toFixed(2), ts: Date.now() };\nreturn msg;",
|
|
45
|
+
"outputs": 1,
|
|
46
|
+
"x": 360,
|
|
47
|
+
"y": 160,
|
|
48
|
+
"wires": [
|
|
49
|
+
[
|
|
50
|
+
"fc-util-portal"
|
|
51
|
+
]
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "fc-util-helpers",
|
|
56
|
+
"type": "fc-portal-utility",
|
|
57
|
+
"z": "fc-util-flow",
|
|
58
|
+
"name": "math helpers + useDebounce",
|
|
59
|
+
"utilName": "mathHelpers",
|
|
60
|
+
"utilCode": "// Constants — bundled once, shared across portals that reference them.\nconst CLAMP_MIN = 0;\nconst CLAMP_MAX = 100;\n\n// Pure helper.\nfunction clamp(n, min, max) {\n return Math.max(min, Math.min(max, n));\n}\n\n// Custom React hook — name MUST start with `use` for React's rules of hooks.\n// This node groups a constant + a helper + a hook in one block; portals that\n// reference any of `clamp`, `useDebounce`, `CLAMP_MIN`, `CLAMP_MAX` pull in\n// the whole node.\nfunction useDebounce(value, ms = 300) {\n const [v, setV] = React.useState(value);\n React.useEffect(() => {\n const t = setTimeout(() => setV(value), ms);\n return () => clearTimeout(t);\n }, [value, ms]);\n return v;\n}",
|
|
61
|
+
"x": 160,
|
|
62
|
+
"y": 280,
|
|
63
|
+
"wires": []
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "fc-util-bar",
|
|
67
|
+
"type": "fc-portal-component",
|
|
68
|
+
"z": "fc-util-flow",
|
|
69
|
+
"name": "Bar",
|
|
70
|
+
"compName": "Bar",
|
|
71
|
+
"compCode": "function Bar({ value = 0, label = '' }) {\n // Component uses `clamp` from the utility node — selective inclusion picks\n // up the dependency through the components' code, not just the user JSX.\n const pct = clamp(value, CLAMP_MIN, CLAMP_MAX);\n const color = pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-amber-500' : 'bg-emerald-500';\n return (\n <div className=\"flex items-center gap-3\">\n <div className=\"w-24 text-xs text-zinc-400 uppercase tracking-wider\">{label}</div>\n <div className=\"flex-1 h-3 bg-zinc-800 rounded-full overflow-hidden\">\n <div className={`h-full ${color} transition-all duration-150`} style={{ width: pct + '%' }} />\n </div>\n <div className=\"w-14 text-right font-mono text-sm tabular-nums text-zinc-200\">\n {value.toFixed(1)}\n </div>\n </div>\n );\n}",
|
|
72
|
+
"x": 360,
|
|
73
|
+
"y": 280,
|
|
74
|
+
"wires": []
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": "fc-util-portal",
|
|
78
|
+
"type": "portal-react",
|
|
79
|
+
"z": "fc-util-flow",
|
|
80
|
+
"name": "Debounce demo",
|
|
81
|
+
"subPath": "debounce",
|
|
82
|
+
"pageTitle": "useDebounce demo",
|
|
83
|
+
"customHead": "",
|
|
84
|
+
"componentCode": "function App() {\n const { data } = useNodeRed();\n const raw = data?.value ?? 0;\n\n // Live raw signal vs debounced views at three different windows.\n // `useDebounce` is declared in the fc-portal-utility node — no import.\n const slow100 = useDebounce(raw, 100);\n const slow400 = useDebounce(raw, 400);\n const slow1500 = useDebounce(raw, 1500);\n\n return (\n <div className=\"min-h-screen bg-zinc-950 text-zinc-100 p-8 font-sans\">\n <h1 className=\"text-2xl font-light text-amber-400 mb-2\">useDebounce</h1>\n <p className=\"text-sm text-zinc-500 mb-8\">\n Custom hook from <code className=\"text-amber-300\">fc-portal-utility</code>. Same noisy 10 Hz input fed through different debounce windows.\n </p>\n <div className=\"max-w-xl space-y-4\">\n <Bar value={raw} label=\"raw\" />\n <Bar value={slow100} label=\"100 ms\" />\n <Bar value={slow400} label=\"400 ms\" />\n <Bar value={slow1500} label=\"1500 ms\" />\n </div>\n </div>\n );\n}",
|
|
85
|
+
"libs": [],
|
|
86
|
+
"portalAuth": false,
|
|
87
|
+
"showWsStatus": false,
|
|
88
|
+
"x": 580,
|
|
89
|
+
"y": 160,
|
|
90
|
+
"wires": [
|
|
91
|
+
[]
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
]
|