feat: ledlab
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
23
README.md
Normal file
23
README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# SPORE LEDLab
|
||||||
|
|
||||||
|
LEDLab is a tool for streaming animations to SPORE nodes that implement the PixelStreamController.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
- listens on UDP port 4210 to discover SPORE nodes
|
||||||
|
- provides a websocket server API to interact with SPORE nodes over UDP
|
||||||
|
- can stream PixelStream presets either directly to a SPORE node or to all nodes using the UDP broadcast address
|
||||||
|
- preset parameters can be adjusted / configured on-the-fly directly through websockets
|
||||||
|
- if a websocket client is connected (UI), the stream is also sent to the websocket client
|
||||||
|
- can forward a stream received from websocket to UDP
|
||||||
|
- default matrix size is 16x16 pixels
|
||||||
|
|
||||||
|
### Web-UI
|
||||||
|
|
||||||
|
- default matrix size can be configured in the UI, default is 16x16 pixel
|
||||||
|
- shows canvas of the currently running stream (scaled up); the stream is received over websocket from the server
|
||||||
|
- preset selection: select any of the presets defined in the backend to be streamed (by the backend) to one or all SPORE nodes
|
||||||
|
- preset configuration: the parameters of a preset can be changed on-the-fly and changes the stream in realtime
|
||||||
|
- the UI is based on the spore-ui project and uses the same component framework and styling. components and other required JS files used in spore-ui are just copied over if needed
|
||||||
863
package-lock.json
generated
Normal file
863
package-lock.json
generated
Normal file
@@ -0,0 +1,863 @@
|
|||||||
|
{
|
||||||
|
"name": "spore-ledlab",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "spore-ledlab",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dgram": "^1.0.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"ws": "^8.14.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/array-flatten": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "1.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
|
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "3.1.2",
|
||||||
|
"content-type": "~1.0.5",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "1.2.0",
|
||||||
|
"http-errors": "2.0.0",
|
||||||
|
"iconv-lite": "0.4.24",
|
||||||
|
"on-finished": "2.4.1",
|
||||||
|
"qs": "6.13.0",
|
||||||
|
"raw-body": "2.5.2",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"unpipe": "1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/destroy": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dgram": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dgram/-/dgram-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zJVFL1EWfKtE0z2VN6qfpn/a+qG1viEzcwJA0EjtzS76ONSE3sEyWBwEbo32hS4IFw/EWVuWN+8b89aPW6It2A==",
|
||||||
|
"deprecated": "npm is holding this package for security reasons. As it's a core Node module, we will not transfer it over to other users. You may safely remove the package from your dependencies.",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "4.21.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
|
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.8",
|
||||||
|
"array-flatten": "1.1.1",
|
||||||
|
"body-parser": "1.20.3",
|
||||||
|
"content-disposition": "0.5.4",
|
||||||
|
"content-type": "~1.0.4",
|
||||||
|
"cookie": "0.7.1",
|
||||||
|
"cookie-signature": "1.0.6",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"finalhandler": "1.3.1",
|
||||||
|
"fresh": "0.5.2",
|
||||||
|
"http-errors": "2.0.0",
|
||||||
|
"merge-descriptors": "1.0.3",
|
||||||
|
"methods": "~1.1.2",
|
||||||
|
"on-finished": "2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"path-to-regexp": "0.1.12",
|
||||||
|
"proxy-addr": "~2.0.7",
|
||||||
|
"qs": "6.13.0",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"send": "0.19.0",
|
||||||
|
"serve-static": "1.16.2",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "2.0.1",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"utils-merge": "1.0.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"on-finished": "2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"statuses": "2.0.1",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fresh": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"inherits": "2.0.4",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "2.0.1",
|
||||||
|
"toidentifier": "1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/methods": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "0.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||||
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||||
|
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body": {
|
||||||
|
"version": "2.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||||
|
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "3.1.2",
|
||||||
|
"http-errors": "2.0.0",
|
||||||
|
"iconv-lite": "0.4.24",
|
||||||
|
"unpipe": "1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "0.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||||
|
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "1.2.0",
|
||||||
|
"encodeurl": "~1.0.2",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"fresh": "0.5.2",
|
||||||
|
"http-errors": "2.0.0",
|
||||||
|
"mime": "1.6.0",
|
||||||
|
"ms": "2.1.3",
|
||||||
|
"on-finished": "2.4.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"statuses": "2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/encodeurl": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"send": "0.19.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-list": "^1.0.0",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/utils-merge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "spore-ledlab",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "LED Lab for streaming animations to SPORE nodes",
|
||||||
|
"main": "server/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server/index.js",
|
||||||
|
"dev": "node server/index.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": ["spore", "led", "animation", "streaming"],
|
||||||
|
"author": "SPORE Team",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"ws": "^8.14.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
111
presets/aurora-curtains-preset.js
Normal file
111
presets/aurora-curtains-preset.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Aurora Curtains preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, toIndex, samplePalette, hexToRgb } = require('./frame-utils');
|
||||||
|
|
||||||
|
class AuroraCurtainsPreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.bands = [];
|
||||||
|
this.defaultParameters = {
|
||||||
|
bandCount: 5,
|
||||||
|
waveSpeed: 0.35,
|
||||||
|
horizontalSway: 0.45,
|
||||||
|
brightness: 1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init();
|
||||||
|
this.createBands();
|
||||||
|
}
|
||||||
|
|
||||||
|
createBands() {
|
||||||
|
const bandCount = this.getParameter('bandCount') || 5;
|
||||||
|
this.bands = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < bandCount; ++index) {
|
||||||
|
this.bands.push({
|
||||||
|
center: Math.random() * (this.width - 1),
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
width: 1.2 + Math.random() * 1.8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
const frame = createFrame(this.width, this.height);
|
||||||
|
const waveSpeed = this.getParameter('waveSpeed') || 0.35;
|
||||||
|
const horizontalSway = this.getParameter('horizontalSway') || 0.45;
|
||||||
|
const brightness = this.getParameter('brightness') || 1.0;
|
||||||
|
const timeSeconds = this.frameCount * 0.05; // Convert frame count to time
|
||||||
|
|
||||||
|
const paletteStops = [
|
||||||
|
{ stop: 0.0, color: hexToRgb('01010a') },
|
||||||
|
{ stop: 0.2, color: hexToRgb('041332') },
|
||||||
|
{ stop: 0.4, color: hexToRgb('0c3857') },
|
||||||
|
{ stop: 0.65, color: hexToRgb('1aa07a') },
|
||||||
|
{ stop: 0.85, color: hexToRgb('68d284') },
|
||||||
|
{ stop: 1.0, color: hexToRgb('f4f5c6') },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let row = 0; row < this.height; ++row) {
|
||||||
|
const verticalRatio = row / Math.max(1, this.height - 1);
|
||||||
|
|
||||||
|
for (let col = 0; col < this.width; ++col) {
|
||||||
|
let intensity = 0;
|
||||||
|
|
||||||
|
this.bands.forEach((band, index) => {
|
||||||
|
const sway = Math.sin(timeSeconds * waveSpeed + band.phase + verticalRatio * Math.PI * 2) * horizontalSway;
|
||||||
|
const center = band.center + sway * (index % 2 === 0 ? 1 : -1);
|
||||||
|
const distance = Math.abs(col - center);
|
||||||
|
const blurred = Math.exp(-(distance * distance) / (2 * band.width * band.width));
|
||||||
|
intensity += blurred * (0.8 + Math.sin(timeSeconds * 0.4 + index) * 0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalized = Math.min(intensity / this.bands.length, 1);
|
||||||
|
const gradientBlend = Math.min((normalized * 0.7 + verticalRatio * 0.3), 1);
|
||||||
|
|
||||||
|
let colorHex = samplePalette(paletteStops, gradientBlend);
|
||||||
|
|
||||||
|
// Apply brightness
|
||||||
|
if (brightness < 1.0) {
|
||||||
|
const rgb = hexToRgb(colorHex);
|
||||||
|
colorHex = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') +
|
||||||
|
Math.round(rgb.g * brightness).toString(16).padStart(2, '0') +
|
||||||
|
Math.round(rgb.b * brightness).toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
frame[toIndex(col, row, this.width)] = colorHex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
setParameter(name, value) {
|
||||||
|
super.setParameter(name, value);
|
||||||
|
|
||||||
|
// Recreate bands if band-related parameters change
|
||||||
|
if (name === 'bandCount') {
|
||||||
|
this.createBands();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Aurora Curtains',
|
||||||
|
description: 'Flowing aurora-like curtains with wave motion',
|
||||||
|
parameters: {
|
||||||
|
bandCount: { type: 'range', min: 3, max: 10, step: 1, default: 5 },
|
||||||
|
waveSpeed: { type: 'range', min: 0.1, max: 2.0, step: 0.05, default: 0.35 },
|
||||||
|
horizontalSway: { type: 'range', min: 0.1, max: 1.0, step: 0.05, default: 0.45 },
|
||||||
|
brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AuroraCurtainsPreset;
|
||||||
86
presets/base-preset.js
Normal file
86
presets/base-preset.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Base preset class for LEDLab
|
||||||
|
|
||||||
|
const { createFrame, frameToPayload } = require('./frame-utils');
|
||||||
|
|
||||||
|
class BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.frameCount = 0;
|
||||||
|
this.isActive = false;
|
||||||
|
this.parameters = {};
|
||||||
|
this.defaultParameters = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the preset with default parameters
|
||||||
|
init() {
|
||||||
|
this.frameCount = 0;
|
||||||
|
this.resetToDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset parameters to their default values
|
||||||
|
resetToDefaults() {
|
||||||
|
this.parameters = { ...this.defaultParameters };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a parameter value
|
||||||
|
setParameter(name, value) {
|
||||||
|
this.parameters[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a parameter value
|
||||||
|
getParameter(name) {
|
||||||
|
return this.parameters[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all parameters
|
||||||
|
getParameters() {
|
||||||
|
return { ...this.parameters };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the preset
|
||||||
|
start() {
|
||||||
|
this.isActive = true;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the preset
|
||||||
|
stop() {
|
||||||
|
this.isActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the next frame
|
||||||
|
generateFrame() {
|
||||||
|
if (!this.isActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = this.renderFrame();
|
||||||
|
this.frameCount++;
|
||||||
|
|
||||||
|
if (frame) {
|
||||||
|
return frameToPayload(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override in subclasses to render a frame
|
||||||
|
renderFrame() {
|
||||||
|
// Default implementation returns a blank frame
|
||||||
|
return createFrame(this.width, this.height, '000000');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get preset metadata
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: this.constructor.name,
|
||||||
|
description: 'Base preset class',
|
||||||
|
parameters: this.defaultParameters,
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BasePreset;
|
||||||
113
presets/bouncing-ball-preset.js
Normal file
113
presets/bouncing-ball-preset.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Bouncing Ball preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, toIndex } = require('./frame-utils');
|
||||||
|
|
||||||
|
class BouncingBallPreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.position = 0;
|
||||||
|
this.velocity = 0;
|
||||||
|
this.defaultParameters = {
|
||||||
|
speed: 1.0,
|
||||||
|
ballSize: 1,
|
||||||
|
trailLength: 5,
|
||||||
|
color: 'ff8000',
|
||||||
|
brightness: 1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init();
|
||||||
|
this.position = Math.random() * (this.width * this.height - 1);
|
||||||
|
this.velocity = this.randomVelocity();
|
||||||
|
}
|
||||||
|
|
||||||
|
randomVelocity() {
|
||||||
|
const min = 0.15;
|
||||||
|
const max = 0.4;
|
||||||
|
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||||
|
return (min + Math.random() * (max - min)) * sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
rebound(sign) {
|
||||||
|
this.velocity = this.randomVelocity() * sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
mix(a, b, t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
const frame = createFrame(this.width, this.height);
|
||||||
|
const speed = this.getParameter('speed') || 1.0;
|
||||||
|
const ballSize = this.getParameter('ballSize') || 1;
|
||||||
|
const trailLength = this.getParameter('trailLength') || 5;
|
||||||
|
const color = this.getParameter('color') || 'ff8000';
|
||||||
|
const brightness = this.getParameter('brightness') || 1.0;
|
||||||
|
|
||||||
|
const dt = 0.016; // Assume 60 FPS
|
||||||
|
this.position += this.velocity * speed * dt * 60;
|
||||||
|
|
||||||
|
// Handle boundary collisions
|
||||||
|
if (this.position < 0) {
|
||||||
|
this.position = -this.position;
|
||||||
|
this.rebound(1);
|
||||||
|
} else if (this.position > this.width * this.height - 1) {
|
||||||
|
this.position = (this.width * this.height - 1) - (this.position - (this.width * this.height - 1));
|
||||||
|
this.rebound(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = Math.max(0, Math.min(this.width * this.height - 1, Math.round(this.position)));
|
||||||
|
|
||||||
|
// Render ball and trail
|
||||||
|
for (let i = 0; i < frame.length; i++) {
|
||||||
|
if (i === activeIndex) {
|
||||||
|
// Main ball
|
||||||
|
frame[i] = color;
|
||||||
|
} else {
|
||||||
|
// Trail effect
|
||||||
|
const distance = Math.abs(i - this.position);
|
||||||
|
if (distance <= trailLength) {
|
||||||
|
const intensity = Math.max(0, 1 - distance / trailLength);
|
||||||
|
const trailColor = this.hexToRgb(color);
|
||||||
|
|
||||||
|
const r = Math.round(this.mix(0, trailColor.r, intensity * brightness));
|
||||||
|
const g = Math.round(this.mix(20, trailColor.g, intensity * brightness));
|
||||||
|
const b = Math.round(this.mix(40, trailColor.b, intensity * brightness));
|
||||||
|
|
||||||
|
frame[i] = r.toString(16).padStart(2, '0') +
|
||||||
|
g.toString(16).padStart(2, '0') +
|
||||||
|
b.toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
hexToRgb(hex) {
|
||||||
|
const r = parseInt(hex.slice(0, 2), 16);
|
||||||
|
const g = parseInt(hex.slice(2, 4), 16);
|
||||||
|
const b = parseInt(hex.slice(4, 6), 16);
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Bouncing Ball',
|
||||||
|
description: 'A ball that bounces around the matrix with a trailing effect',
|
||||||
|
parameters: {
|
||||||
|
speed: { type: 'range', min: 0.1, max: 3.0, step: 0.1, default: 1.0 },
|
||||||
|
ballSize: { type: 'range', min: 1, max: 5, step: 1, default: 1 },
|
||||||
|
trailLength: { type: 'range', min: 1, max: 10, step: 1, default: 5 },
|
||||||
|
color: { type: 'color', default: 'ff8000' },
|
||||||
|
brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BouncingBallPreset;
|
||||||
152
presets/circuit-pulse-preset.js
Normal file
152
presets/circuit-pulse-preset.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// Circuit Pulse preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, fadeFrame, frameToPayload, hexToRgb, samplePalette, toIndex, addHexColor } = require('./frame-utils');
|
||||||
|
|
||||||
|
class CircuitPulsePreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.paths = [];
|
||||||
|
this.pulses = [];
|
||||||
|
this.paletteStops = [
|
||||||
|
{ stop: 0.0, color: hexToRgb('020209') },
|
||||||
|
{ stop: 0.3, color: hexToRgb('023047') },
|
||||||
|
{ stop: 0.6, color: hexToRgb('115173') },
|
||||||
|
{ stop: 0.8, color: hexToRgb('1ca78f') },
|
||||||
|
{ stop: 1.0, color: hexToRgb('94fdf3') },
|
||||||
|
];
|
||||||
|
this.accentColors = ['14f5ff', 'a7ff4d', 'ffcc3f'];
|
||||||
|
this.defaultParameters = {
|
||||||
|
pathFade: 0.85,
|
||||||
|
pulseLength: 6,
|
||||||
|
pulseSpeed: 5.0,
|
||||||
|
pulseCount: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init();
|
||||||
|
this.paths = this.createPaths(this.width, this.height);
|
||||||
|
this.pulses = this.createPulses(this.paths.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
createPaths(matrixWidth, matrixHeight) {
|
||||||
|
const horizontalStep = Math.max(2, Math.floor(matrixHeight / 4));
|
||||||
|
const verticalStep = Math.max(2, Math.floor(matrixWidth / 4));
|
||||||
|
const generatedPaths = [];
|
||||||
|
|
||||||
|
// Horizontal paths
|
||||||
|
for (let y = 1; y < matrixHeight; y += horizontalStep) {
|
||||||
|
const path = [];
|
||||||
|
for (let x = 0; x < matrixWidth; ++x) {
|
||||||
|
path.push({ x, y });
|
||||||
|
}
|
||||||
|
generatedPaths.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical paths
|
||||||
|
for (let x = 2; x < matrixWidth; x += verticalStep) {
|
||||||
|
const path = [];
|
||||||
|
for (let y = 0; y < matrixHeight; ++y) {
|
||||||
|
path.push({ x, y });
|
||||||
|
}
|
||||||
|
generatedPaths.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generatedPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
createPulses(count) {
|
||||||
|
const pulseList = [];
|
||||||
|
for (let index = 0; index < count; ++index) {
|
||||||
|
pulseList.push(this.spawnPulse(index));
|
||||||
|
}
|
||||||
|
return pulseList;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnPulse(pathIndex) {
|
||||||
|
const color = this.accentColors[pathIndex % this.accentColors.length];
|
||||||
|
return {
|
||||||
|
pathIndex,
|
||||||
|
position: 0,
|
||||||
|
speed: 3 + Math.random() * 2,
|
||||||
|
color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePulse(pulse, deltaSeconds) {
|
||||||
|
pulse.position += pulse.speed * deltaSeconds;
|
||||||
|
const path = this.paths[pulse.pathIndex];
|
||||||
|
|
||||||
|
if (!path || path.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pulse.position >= path.length + this.getParameter('pulseLength')) {
|
||||||
|
Object.assign(pulse, this.spawnPulse(pulse.pathIndex));
|
||||||
|
pulse.position = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPulse(pulse) {
|
||||||
|
const path = this.paths[pulse.pathIndex];
|
||||||
|
if (!path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pulseLength = this.getParameter('pulseLength');
|
||||||
|
|
||||||
|
for (let offset = 0; offset < pulseLength; ++offset) {
|
||||||
|
const index = Math.floor(pulse.position) - offset;
|
||||||
|
if (index < 0 || index >= path.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x, y } = path[index];
|
||||||
|
const intensity = Math.max(0, 1 - offset / pulseLength);
|
||||||
|
const baseColor = samplePalette(this.paletteStops, intensity);
|
||||||
|
this.frame[toIndex(x, y, this.width)] = baseColor;
|
||||||
|
addHexColor(this.frame, toIndex(x, y, this.width), pulse.color, intensity * 1.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
this.frame = createFrame(this.width, this.height);
|
||||||
|
const pathFade = this.getParameter('pathFade') || 0.85;
|
||||||
|
const pulseCount = this.getParameter('pulseCount') || 3;
|
||||||
|
|
||||||
|
fadeFrame(this.frame, pathFade);
|
||||||
|
|
||||||
|
// Update pulse count if it changed
|
||||||
|
while (this.pulses.length < pulseCount) {
|
||||||
|
this.pulses.push(this.spawnPulse(this.pulses.length));
|
||||||
|
}
|
||||||
|
while (this.pulses.length > pulseCount) {
|
||||||
|
this.pulses.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pulses.forEach((pulse) => {
|
||||||
|
this.updatePulse(pulse, 0.016); // Assume 60 FPS
|
||||||
|
this.renderPulse(pulse);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Circuit Pulse',
|
||||||
|
description: 'Animated circuit board with pulsing paths',
|
||||||
|
parameters: {
|
||||||
|
pathFade: { type: 'range', min: 0.7, max: 0.95, step: 0.05, default: 0.85 },
|
||||||
|
pulseLength: { type: 'range', min: 3, max: 12, step: 1, default: 6 },
|
||||||
|
pulseSpeed: { type: 'range', min: 2.0, max: 8.0, step: 0.5, default: 5.0 },
|
||||||
|
pulseCount: { type: 'range', min: 1, max: 6, step: 1, default: 3 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CircuitPulsePreset;
|
||||||
51
presets/fade-green-blue-preset.js
Normal file
51
presets/fade-green-blue-preset.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Fade Green Blue preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, frameToPayload } = require('./frame-utils');
|
||||||
|
|
||||||
|
class FadeGreenBluePreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.tick = 0;
|
||||||
|
this.defaultParameters = {
|
||||||
|
speed: 0.5, // cycles per second
|
||||||
|
brightness: 1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
const frame = createFrame(this.width, this.height);
|
||||||
|
const timeSeconds = (this.tick * 0.016); // Assume 60 FPS
|
||||||
|
const phase = timeSeconds * this.getParameter('speed') * Math.PI * 2;
|
||||||
|
const blend = (Math.sin(phase) + 1) * 0.5; // 0..1
|
||||||
|
|
||||||
|
const brightness = this.getParameter('brightness') || 1.0;
|
||||||
|
const green = Math.round(255 * (1 - blend) * brightness);
|
||||||
|
const blue = Math.round(255 * blend * brightness);
|
||||||
|
|
||||||
|
const gHex = green.toString(16).padStart(2, '0');
|
||||||
|
const bHex = blue.toString(16).padStart(2, '0');
|
||||||
|
|
||||||
|
for (let i = 0; i < frame.length; i++) {
|
||||||
|
frame[i] = '00' + gHex + bHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tick++;
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Fade Green Blue',
|
||||||
|
description: 'Smooth fade between green and blue colors',
|
||||||
|
parameters: {
|
||||||
|
speed: { type: 'range', min: 0.1, max: 2.0, step: 0.1, default: 0.5 },
|
||||||
|
brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FadeGreenBluePreset;
|
||||||
133
presets/frame-utils.js
Normal file
133
presets/frame-utils.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// Frame utilities for LEDLab presets
|
||||||
|
|
||||||
|
const SERPENTINE_WIRING = true;
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const normalizedHex = hex.trim().toLowerCase();
|
||||||
|
const value = normalizedHex.startsWith('#') ? normalizedHex.slice(1) : normalizedHex;
|
||||||
|
return {
|
||||||
|
r: parseInt(value.slice(0, 2), 16),
|
||||||
|
g: parseInt(value.slice(2, 4), 16),
|
||||||
|
b: parseInt(value.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(rgb) {
|
||||||
|
return toHex(rgb.r) + toHex(rgb.g) + toHex(rgb.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(value) {
|
||||||
|
const boundedValue = clamp(Math.round(value), 0, 255);
|
||||||
|
const hex = boundedValue.toString(16);
|
||||||
|
return hex.length === 1 ? '0' + hex : hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerpRgb(lhs, rhs, t) {
|
||||||
|
return {
|
||||||
|
r: Math.round(lhs.r + (rhs.r - lhs.r) * t),
|
||||||
|
g: Math.round(lhs.g + (rhs.g - lhs.g) * t),
|
||||||
|
b: Math.round(lhs.b + (rhs.b - lhs.b) * t),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function samplePalette(paletteStops, value) {
|
||||||
|
const clampedValue = clamp(value, 0, 1);
|
||||||
|
|
||||||
|
for (let index = 0; index < paletteStops.length - 1; ++index) {
|
||||||
|
const left = paletteStops[index];
|
||||||
|
const right = paletteStops[index + 1];
|
||||||
|
if (clampedValue <= right.stop) {
|
||||||
|
const span = right.stop - left.stop || 1;
|
||||||
|
const t = clamp((clampedValue - left.stop) / span, 0, 1);
|
||||||
|
const interpolatedColor = lerpRgb(left.color, right.color, t);
|
||||||
|
return rgbToHex(interpolatedColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rgbToHex(paletteStops[paletteStops.length - 1].color);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIndex(col, row, width, serpentine = SERPENTINE_WIRING) {
|
||||||
|
if (!serpentine || row % 2 === 0) {
|
||||||
|
return row * width + col;
|
||||||
|
}
|
||||||
|
return row * width + (width - 1 - col);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFrame(width, height, fill = '000000') {
|
||||||
|
return new Array(width * height).fill(fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameToPayload(frame) {
|
||||||
|
return 'RAW:' + frame.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fadeFrame(frame, factor) {
|
||||||
|
for (let index = 0; index < frame.length; ++index) {
|
||||||
|
const hex = frame[index];
|
||||||
|
const r = parseInt(hex.slice(0, 2), 16) * factor;
|
||||||
|
const g = parseInt(hex.slice(2, 4), 16) * factor;
|
||||||
|
const b = parseInt(hex.slice(4, 6), 16) * factor;
|
||||||
|
frame[index] = toHex(r) + toHex(g) + toHex(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRgbToFrame(frame, index, rgb) {
|
||||||
|
if (index < 0 || index >= frame.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = hexToRgb(frame[index]);
|
||||||
|
const updated = {
|
||||||
|
r: clamp(current.r + rgb.r, 0, 255),
|
||||||
|
g: clamp(current.g + rgb.g, 0, 255),
|
||||||
|
b: clamp(current.b + rgb.b, 0, 255),
|
||||||
|
};
|
||||||
|
frame[index] = rgbToHex(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHexColor(frame, index, hexColor, intensity = 1) {
|
||||||
|
if (intensity <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = hexToRgb(hexColor);
|
||||||
|
addRgbToFrame(frame, index, {
|
||||||
|
r: base.r * intensity,
|
||||||
|
g: base.g * intensity,
|
||||||
|
b: base.b * intensity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color wheel function for rainbow effects
|
||||||
|
function wheel(pos) {
|
||||||
|
pos = 255 - pos;
|
||||||
|
if (pos < 85) {
|
||||||
|
return [255 - pos * 3, 0, pos * 3];
|
||||||
|
}
|
||||||
|
if (pos < 170) {
|
||||||
|
pos -= 85;
|
||||||
|
return [0, pos * 3, 255 - pos * 3];
|
||||||
|
}
|
||||||
|
pos -= 170;
|
||||||
|
return [pos * 3, 255 - pos * 3, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
clamp,
|
||||||
|
hexToRgb,
|
||||||
|
rgbToHex,
|
||||||
|
lerpRgb,
|
||||||
|
samplePalette,
|
||||||
|
toIndex,
|
||||||
|
createFrame,
|
||||||
|
frameToPayload,
|
||||||
|
fadeFrame,
|
||||||
|
addRgbToFrame,
|
||||||
|
addHexColor,
|
||||||
|
wheel,
|
||||||
|
};
|
||||||
146
presets/lava-lamp-preset.js
Normal file
146
presets/lava-lamp-preset.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Lava Lamp preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils');
|
||||||
|
|
||||||
|
class LavaLampPreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.blobs = [];
|
||||||
|
this.paletteStops = [
|
||||||
|
{ stop: 0.0, color: hexToRgb('050319') },
|
||||||
|
{ stop: 0.28, color: hexToRgb('2a0c4f') },
|
||||||
|
{ stop: 0.55, color: hexToRgb('8f1f73') },
|
||||||
|
{ stop: 0.75, color: hexToRgb('ff4a22') },
|
||||||
|
{ stop: 0.9, color: hexToRgb('ff9333') },
|
||||||
|
{ stop: 1.0, color: hexToRgb('fff7b0') },
|
||||||
|
];
|
||||||
|
this.defaultParameters = {
|
||||||
|
blobCount: 6,
|
||||||
|
blobSpeed: 0.18,
|
||||||
|
minBlobRadius: 0.18,
|
||||||
|
maxBlobRadius: 0.38,
|
||||||
|
intensity: 1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init();
|
||||||
|
this.blobs = this.createBlobs(this.getParameter('blobCount') || 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
createBlobs(count) {
|
||||||
|
const blobList = [];
|
||||||
|
const maxAxis = Math.max(this.width, this.height);
|
||||||
|
const minBlobRadius = Math.max(3, maxAxis * (this.getParameter('minBlobRadius') || 0.18));
|
||||||
|
const maxBlobRadius = Math.max(minBlobRadius + 1, maxAxis * (this.getParameter('maxBlobRadius') || 0.38));
|
||||||
|
|
||||||
|
for (let index = 0; index < count; ++index) {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const speed = (this.getParameter('blobSpeed') || 0.18) * (0.6 + Math.random() * 0.8);
|
||||||
|
blobList.push({
|
||||||
|
x: Math.random() * Math.max(1, this.width - 1),
|
||||||
|
y: Math.random() * Math.max(1, this.height - 1),
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
minRadius: minBlobRadius * (0.6 + Math.random() * 0.3),
|
||||||
|
maxRadius: maxBlobRadius * (0.8 + Math.random() * 0.4),
|
||||||
|
intensity: (this.getParameter('intensity') || 1.0) * (0.8 + Math.random() * 0.7),
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
phaseVelocity: 0.6 + Math.random() * 0.6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return blobList;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBlobs(deltaSeconds) {
|
||||||
|
const maxX = Math.max(0, this.width - 1);
|
||||||
|
const maxY = Math.max(0, this.height - 1);
|
||||||
|
|
||||||
|
this.blobs.forEach((blob) => {
|
||||||
|
blob.x += blob.vx * deltaSeconds;
|
||||||
|
blob.y += blob.vy * deltaSeconds;
|
||||||
|
|
||||||
|
if (blob.x < 0) {
|
||||||
|
blob.x = -blob.x;
|
||||||
|
blob.vx = Math.abs(blob.vx);
|
||||||
|
} else if (blob.x > maxX) {
|
||||||
|
blob.x = 2 * maxX - blob.x;
|
||||||
|
blob.vx = -Math.abs(blob.vx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blob.y < 0) {
|
||||||
|
blob.y = -blob.y;
|
||||||
|
blob.vy = Math.abs(blob.vy);
|
||||||
|
} else if (blob.y > maxY) {
|
||||||
|
blob.y = 2 * maxY - blob.y;
|
||||||
|
blob.vy = -Math.abs(blob.vy);
|
||||||
|
}
|
||||||
|
|
||||||
|
blob.phase += blob.phaseVelocity * deltaSeconds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
this.updateBlobs(0.016); // Assume 60 FPS
|
||||||
|
|
||||||
|
const frame = createFrame(this.width, this.height);
|
||||||
|
|
||||||
|
for (let row = 0; row < this.height; ++row) {
|
||||||
|
for (let col = 0; col < this.width; ++col) {
|
||||||
|
const energy = this.calculateEnergyAt(col, row);
|
||||||
|
const color = samplePalette(this.paletteStops, energy);
|
||||||
|
frame[toIndex(col, row, this.width)] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateEnergyAt(col, row) {
|
||||||
|
let energy = 0;
|
||||||
|
|
||||||
|
this.blobs.forEach((blob) => {
|
||||||
|
const radius = this.getBlobRadius(blob);
|
||||||
|
const dx = col - blob.x;
|
||||||
|
const dy = row - blob.y;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
const falloff = Math.max(0, 1 - distance / radius);
|
||||||
|
energy += blob.intensity * falloff * falloff;
|
||||||
|
});
|
||||||
|
|
||||||
|
return clamp(energy / this.blobs.length, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlobRadius(blob) {
|
||||||
|
const oscillation = (Math.sin(blob.phase) + 1) * 0.5;
|
||||||
|
return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation;
|
||||||
|
}
|
||||||
|
|
||||||
|
setParameter(name, value) {
|
||||||
|
super.setParameter(name, value);
|
||||||
|
|
||||||
|
// Recreate blobs if blob-related parameters change
|
||||||
|
if (name === 'blobCount' || name === 'blobSpeed' || name === 'minBlobRadius' || name === 'maxBlobRadius' || name === 'intensity') {
|
||||||
|
this.blobs = this.createBlobs(this.getParameter('blobCount') || 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Lava Lamp',
|
||||||
|
description: 'Floating blobs creating organic color gradients',
|
||||||
|
parameters: {
|
||||||
|
blobCount: { type: 'range', min: 3, max: 12, step: 1, default: 6 },
|
||||||
|
blobSpeed: { type: 'range', min: 0.1, max: 0.5, step: 0.05, default: 0.18 },
|
||||||
|
minBlobRadius: { type: 'range', min: 0.1, max: 0.3, step: 0.02, default: 0.18 },
|
||||||
|
maxBlobRadius: { type: 'range', min: 0.2, max: 0.5, step: 0.02, default: 0.38 },
|
||||||
|
intensity: { type: 'range', min: 0.5, max: 2.0, step: 0.1, default: 1.0 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LavaLampPreset;
|
||||||
108
presets/meteor-rain-preset.js
Normal file
108
presets/meteor-rain-preset.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Meteor Rain preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, fadeFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils');
|
||||||
|
|
||||||
|
class MeteorRainPreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.meteors = [];
|
||||||
|
this.paletteStops = [
|
||||||
|
{ stop: 0.0, color: hexToRgb('0a0126') },
|
||||||
|
{ stop: 0.3, color: hexToRgb('123d8b') },
|
||||||
|
{ stop: 0.7, color: hexToRgb('21c7d9') },
|
||||||
|
{ stop: 1.0, color: hexToRgb('f7ffff') },
|
||||||
|
];
|
||||||
|
this.defaultParameters = {
|
||||||
|
meteorCount: 12,
|
||||||
|
minSpeed: 4,
|
||||||
|
maxSpeed: 10,
|
||||||
|
trailDecay: 0.76,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init();
|
||||||
|
this.meteors = this.createMeteors(this.getParameter('meteorCount') || 12, this.width, this.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
createMeteors(count, matrixWidth, matrixHeight) {
|
||||||
|
const meteorList = [];
|
||||||
|
for (let index = 0; index < count; ++index) {
|
||||||
|
meteorList.push(this.spawnMeteor(matrixWidth, matrixHeight));
|
||||||
|
}
|
||||||
|
return meteorList;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnMeteor(matrixWidth, matrixHeight) {
|
||||||
|
const angle = (Math.PI / 4) * (0.6 + Math.random() * 0.8);
|
||||||
|
const speed = (this.getParameter('minSpeed') || 4) + Math.random() * ((this.getParameter('maxSpeed') || 10) - (this.getParameter('minSpeed') || 4));
|
||||||
|
return {
|
||||||
|
x: Math.random() * matrixWidth,
|
||||||
|
y: -Math.random() * matrixHeight,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
drawMeteor(meteor) {
|
||||||
|
const col = Math.round(meteor.x);
|
||||||
|
const row = Math.round(meteor.y);
|
||||||
|
if (col < 0 || col >= this.width || row < 0 || row >= this.height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const energy = clamp(1.2 - Math.random() * 0.2, 0, 1);
|
||||||
|
this.frame[toIndex(col, row, this.width)] = samplePalette(this.paletteStops, energy);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMeteors(deltaSeconds) {
|
||||||
|
this.meteors.forEach((meteor, index) => {
|
||||||
|
meteor.x += meteor.vx * deltaSeconds;
|
||||||
|
meteor.y += meteor.vy * deltaSeconds;
|
||||||
|
|
||||||
|
this.drawMeteor(meteor);
|
||||||
|
|
||||||
|
if (meteor.x > this.width + 1 || meteor.y > this.height + 1) {
|
||||||
|
this.meteors[index] = this.spawnMeteor(this.width, this.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
this.frame = createFrame(this.width, this.height);
|
||||||
|
const trailDecay = this.getParameter('trailDecay') || 0.76;
|
||||||
|
const meteorCount = this.getParameter('meteorCount') || 12;
|
||||||
|
|
||||||
|
fadeFrame(this.frame, trailDecay);
|
||||||
|
|
||||||
|
// Update meteor count if it changed
|
||||||
|
while (this.meteors.length < meteorCount) {
|
||||||
|
this.meteors.push(this.spawnMeteor(this.width, this.height));
|
||||||
|
}
|
||||||
|
while (this.meteors.length > meteorCount) {
|
||||||
|
this.meteors.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateMeteors(0.016); // Assume 60 FPS
|
||||||
|
|
||||||
|
return this.frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Meteor Rain',
|
||||||
|
description: 'Falling meteors with trailing effects',
|
||||||
|
parameters: {
|
||||||
|
meteorCount: { type: 'range', min: 5, max: 20, step: 1, default: 12 },
|
||||||
|
minSpeed: { type: 'range', min: 2, max: 8, step: 1, default: 4 },
|
||||||
|
maxSpeed: { type: 'range', min: 6, max: 15, step: 1, default: 10 },
|
||||||
|
trailDecay: { type: 'range', min: 0.6, max: 0.9, step: 0.02, default: 0.76 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MeteorRainPreset;
|
||||||
81
presets/nebula-drift-preset.js
Normal file
81
presets/nebula-drift-preset.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// Nebula Drift preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils');
|
||||||
|
|
||||||
|
class NebulaDriftPreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.timeSeconds = 0;
|
||||||
|
this.paletteStops = [
|
||||||
|
{ stop: 0.0, color: hexToRgb('100406') },
|
||||||
|
{ stop: 0.25, color: hexToRgb('2e0f1f') },
|
||||||
|
{ stop: 0.5, color: hexToRgb('6a1731') },
|
||||||
|
{ stop: 0.7, color: hexToRgb('b63b32') },
|
||||||
|
{ stop: 0.85, color: hexToRgb('f48b2a') },
|
||||||
|
{ stop: 1.0, color: hexToRgb('ffe9b0') },
|
||||||
|
];
|
||||||
|
this.defaultParameters = {
|
||||||
|
primarySpeed: 0.15,
|
||||||
|
secondarySpeed: 0.32,
|
||||||
|
waveScale: 0.75,
|
||||||
|
brightness: 1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
layeredWave(u, v, speed, offset) {
|
||||||
|
return Math.sin((u * 3 + v * 2) * Math.PI * this.getParameter('waveScale') + this.timeSeconds * speed + offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
this.timeSeconds += 0.016; // Assume 60 FPS
|
||||||
|
|
||||||
|
const frame = createFrame(this.width, this.height);
|
||||||
|
const brightness = this.getParameter('brightness') || 1.0;
|
||||||
|
|
||||||
|
for (let row = 0; row < this.height; ++row) {
|
||||||
|
const v = row / Math.max(1, this.height - 1);
|
||||||
|
for (let col = 0; col < this.width; ++col) {
|
||||||
|
const u = col / Math.max(1, this.width - 1);
|
||||||
|
const primary = this.layeredWave(u, v, this.getParameter('primarySpeed') || 0.15, 0);
|
||||||
|
const secondary = this.layeredWave(v, u, this.getParameter('secondarySpeed') || 0.32, Math.PI / 4);
|
||||||
|
const tertiary = Math.sin((u + v) * Math.PI * 1.5 + this.timeSeconds * 0.18);
|
||||||
|
|
||||||
|
const combined = 0.45 * primary + 0.35 * secondary + 0.2 * tertiary;
|
||||||
|
const envelope = Math.sin((u * v) * Math.PI * 2 + this.timeSeconds * 0.1) * 0.25 + 0.75;
|
||||||
|
const value = clamp((combined * 0.5 + 0.5) * envelope, 0, 1);
|
||||||
|
|
||||||
|
let color = samplePalette(this.paletteStops, value);
|
||||||
|
|
||||||
|
// Apply brightness
|
||||||
|
if (brightness < 1.0) {
|
||||||
|
const rgb = hexToRgb(color);
|
||||||
|
color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') +
|
||||||
|
Math.round(rgb.g * brightness).toString(16).padStart(2, '0') +
|
||||||
|
Math.round(rgb.b * brightness).toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
frame[toIndex(col, row, this.width)] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Nebula Drift',
|
||||||
|
description: 'Organic drifting nebula with layered wave patterns',
|
||||||
|
parameters: {
|
||||||
|
primarySpeed: { type: 'range', min: 0.05, max: 0.3, step: 0.05, default: 0.15 },
|
||||||
|
secondarySpeed: { type: 'range', min: 0.2, max: 0.5, step: 0.02, default: 0.32 },
|
||||||
|
waveScale: { type: 'range', min: 0.5, max: 1.0, step: 0.05, default: 0.75 },
|
||||||
|
brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = NebulaDriftPreset;
|
||||||
79
presets/ocean-glimmer-preset.js
Normal file
79
presets/ocean-glimmer-preset.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// Ocean Glimmer preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils');
|
||||||
|
|
||||||
|
class OceanGlimmerPreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.timeSeconds = 0;
|
||||||
|
this.paletteStops = [
|
||||||
|
{ stop: 0.0, color: hexToRgb('031521') },
|
||||||
|
{ stop: 0.35, color: hexToRgb('024f6d') },
|
||||||
|
{ stop: 0.65, color: hexToRgb('13a4a1') },
|
||||||
|
{ stop: 0.85, color: hexToRgb('67dcd0') },
|
||||||
|
{ stop: 1.0, color: hexToRgb('fcdba4') },
|
||||||
|
];
|
||||||
|
this.defaultParameters = {
|
||||||
|
shimmer: 0.08,
|
||||||
|
waveSpeed1: 1.2,
|
||||||
|
waveSpeed2: 0.9,
|
||||||
|
waveSpeed3: 0.5,
|
||||||
|
brightness: 1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
this.timeSeconds += 0.016; // Assume 60 FPS
|
||||||
|
|
||||||
|
const frame = createFrame(this.width, this.height);
|
||||||
|
const shimmer = this.getParameter('shimmer') || 0.08;
|
||||||
|
const brightness = this.getParameter('brightness') || 1.0;
|
||||||
|
|
||||||
|
for (let row = 0; row < this.height; ++row) {
|
||||||
|
const v = row / Math.max(1, this.height - 1);
|
||||||
|
for (let col = 0; col < this.width; ++col) {
|
||||||
|
const u = col / Math.max(1, this.width - 1);
|
||||||
|
const base =
|
||||||
|
0.33 +
|
||||||
|
0.26 * Math.sin(u * Math.PI * 2 + this.timeSeconds * (this.getParameter('waveSpeed1') || 1.2)) +
|
||||||
|
0.26 * Math.sin(v * Math.PI * 2 - this.timeSeconds * (this.getParameter('waveSpeed2') || 0.9)) +
|
||||||
|
0.26 * Math.sin((u + v) * Math.PI * 2 + this.timeSeconds * (this.getParameter('waveSpeed3') || 0.5));
|
||||||
|
const noise = (Math.random() - 0.5) * shimmer;
|
||||||
|
const value = clamp(base + noise, 0, 1);
|
||||||
|
|
||||||
|
let color = samplePalette(this.paletteStops, value);
|
||||||
|
|
||||||
|
// Apply brightness
|
||||||
|
if (brightness < 1.0) {
|
||||||
|
const rgb = hexToRgb(color);
|
||||||
|
color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') +
|
||||||
|
Math.round(rgb.g * brightness).toString(16).padStart(2, '0') +
|
||||||
|
Math.round(rgb.b * brightness).toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
frame[toIndex(col, row, this.width)] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Ocean Glimmer',
|
||||||
|
description: 'Ocean-like waves with shimmer effects',
|
||||||
|
parameters: {
|
||||||
|
shimmer: { type: 'range', min: 0.02, max: 0.15, step: 0.01, default: 0.08 },
|
||||||
|
waveSpeed1: { type: 'range', min: 0.5, max: 2.0, step: 0.1, default: 1.2 },
|
||||||
|
waveSpeed2: { type: 'range', min: 0.5, max: 2.0, step: 0.1, default: 0.9 },
|
||||||
|
waveSpeed3: { type: 'range', min: 0.2, max: 1.0, step: 0.1, default: 0.5 },
|
||||||
|
brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OceanGlimmerPreset;
|
||||||
74
presets/preset-registry.js
Normal file
74
presets/preset-registry.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Preset registry for LEDLab
|
||||||
|
|
||||||
|
const RainbowPreset = require('./rainbow-preset');
|
||||||
|
const AuroraCurtainsPreset = require('./aurora-curtains-preset');
|
||||||
|
const BouncingBallPreset = require('./bouncing-ball-preset');
|
||||||
|
const CircuitPulsePreset = require('./circuit-pulse-preset');
|
||||||
|
const FadeGreenBluePreset = require('./fade-green-blue-preset');
|
||||||
|
const LavaLampPreset = require('./lava-lamp-preset');
|
||||||
|
const MeteorRainPreset = require('./meteor-rain-preset');
|
||||||
|
const NebulaDriftPreset = require('./nebula-drift-preset');
|
||||||
|
const OceanGlimmerPreset = require('./ocean-glimmer-preset');
|
||||||
|
const SpiralBloomPreset = require('./spiral-bloom-preset');
|
||||||
|
const VoxelFirefliesPreset = require('./voxel-fireflies-preset');
|
||||||
|
const WormholeTunnelPreset = require('./wormhole-tunnel-preset');
|
||||||
|
|
||||||
|
class PresetRegistry {
|
||||||
|
constructor() {
|
||||||
|
this.presets = new Map();
|
||||||
|
this.registerDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDefaults() {
|
||||||
|
this.register('rainbow', RainbowPreset);
|
||||||
|
this.register('aurora-curtains', AuroraCurtainsPreset);
|
||||||
|
this.register('bouncing-ball', BouncingBallPreset);
|
||||||
|
this.register('circuit-pulse', CircuitPulsePreset);
|
||||||
|
this.register('fade-green-blue', FadeGreenBluePreset);
|
||||||
|
this.register('lava-lamp', LavaLampPreset);
|
||||||
|
this.register('meteor-rain', MeteorRainPreset);
|
||||||
|
this.register('nebula-drift', NebulaDriftPreset);
|
||||||
|
this.register('ocean-glimmer', OceanGlimmerPreset);
|
||||||
|
this.register('spiral-bloom', SpiralBloomPreset);
|
||||||
|
this.register('voxel-fireflies', VoxelFirefliesPreset);
|
||||||
|
this.register('wormhole-tunnel', WormholeTunnelPreset);
|
||||||
|
}
|
||||||
|
|
||||||
|
register(name, presetClass) {
|
||||||
|
this.presets.set(name, presetClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
createPreset(name, width = 16, height = 16) {
|
||||||
|
const presetClass = this.presets.get(name);
|
||||||
|
if (!presetClass) {
|
||||||
|
throw new Error(`Preset '${name}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new presetClass(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPresetNames() {
|
||||||
|
return Array.from(this.presets.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
getPresetMetadata(name) {
|
||||||
|
try {
|
||||||
|
const preset = this.createPreset(name);
|
||||||
|
return preset.getMetadata();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting metadata for preset '${name}':`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllPresetMetadata() {
|
||||||
|
const metadata = {};
|
||||||
|
for (const name of this.getPresetNames()) {
|
||||||
|
metadata[name] = this.getPresetMetadata(name);
|
||||||
|
}
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the constructor class
|
||||||
|
module.exports = PresetRegistry;
|
||||||
53
presets/rainbow-preset.js
Normal file
53
presets/rainbow-preset.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Rainbow preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, wheel } = require('./frame-utils');
|
||||||
|
|
||||||
|
class RainbowPreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.defaultParameters = {
|
||||||
|
speed: 1,
|
||||||
|
brightness: 1.0,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
const frame = createFrame(this.width, this.height);
|
||||||
|
const speed = this.getParameter('speed') || 1;
|
||||||
|
const brightness = this.getParameter('brightness') || 1.0;
|
||||||
|
const offset = (this.getParameter('offset') || 0) + this.frameCount * speed;
|
||||||
|
|
||||||
|
for (let i = 0; i < frame.length; i++) {
|
||||||
|
const colorIndex = (i * 256 / frame.length + offset) & 255;
|
||||||
|
const [r, g, b] = wheel(colorIndex);
|
||||||
|
|
||||||
|
// Apply brightness
|
||||||
|
const adjustedR = Math.round(r * brightness);
|
||||||
|
const adjustedG = Math.round(g * brightness);
|
||||||
|
const adjustedB = Math.round(b * brightness);
|
||||||
|
|
||||||
|
frame[i] = adjustedR.toString(16).padStart(2, '0') +
|
||||||
|
adjustedG.toString(16).padStart(2, '0') +
|
||||||
|
adjustedB.toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Rainbow',
|
||||||
|
description: 'Classic rainbow pattern that cycles through colors',
|
||||||
|
parameters: {
|
||||||
|
speed: { type: 'range', min: 0.1, max: 5.0, step: 0.1, default: 1.0 },
|
||||||
|
brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = RainbowPreset;
|
||||||
81
presets/spiral-bloom-preset.js
Normal file
81
presets/spiral-bloom-preset.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// Spiral Bloom preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex } = require('./frame-utils');
|
||||||
|
|
||||||
|
class SpiralBloomPreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.rotation = 0;
|
||||||
|
this.hueShift = 0;
|
||||||
|
this.paletteStops = [
|
||||||
|
{ stop: 0.0, color: hexToRgb('051923') },
|
||||||
|
{ stop: 0.2, color: hexToRgb('0c4057') },
|
||||||
|
{ stop: 0.45, color: hexToRgb('1d7a70') },
|
||||||
|
{ stop: 0.7, color: hexToRgb('39b15f') },
|
||||||
|
{ stop: 0.88, color: hexToRgb('9dd54c') },
|
||||||
|
{ stop: 1.0, color: hexToRgb('f7f5bc') },
|
||||||
|
];
|
||||||
|
this.defaultParameters = {
|
||||||
|
rotationSpeed: 0.7,
|
||||||
|
hueSpeed: 0.2,
|
||||||
|
spiralArms: 5,
|
||||||
|
brightness: 1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
this.rotation += 0.016 * (this.getParameter('rotationSpeed') || 0.7);
|
||||||
|
this.hueShift += 0.016 * (this.getParameter('hueSpeed') || 0.2);
|
||||||
|
|
||||||
|
const frame = createFrame(this.width, this.height);
|
||||||
|
const brightness = this.getParameter('brightness') || 1.0;
|
||||||
|
const spiralArms = this.getParameter('spiralArms') || 5;
|
||||||
|
|
||||||
|
const cx = (this.width - 1) / 2;
|
||||||
|
const cy = (this.height - 1) / 2;
|
||||||
|
const radiusNorm = Math.hypot(cx, cy) || 1;
|
||||||
|
|
||||||
|
for (let row = 0; row < this.height; ++row) {
|
||||||
|
for (let col = 0; col < this.width; ++col) {
|
||||||
|
const dx = col - cx;
|
||||||
|
const dy = row - cy;
|
||||||
|
const radius = Math.hypot(dx, dy) / radiusNorm;
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
const arm = 0.5 + 0.5 * Math.sin(spiralArms * (angle + this.rotation) + this.hueShift * Math.PI * 2);
|
||||||
|
const value = Math.min(1, radius * 0.8 + arm * 0.4);
|
||||||
|
|
||||||
|
let color = samplePalette(this.paletteStops, value);
|
||||||
|
|
||||||
|
// Apply brightness
|
||||||
|
if (brightness < 1.0) {
|
||||||
|
const rgb = hexToRgb(color);
|
||||||
|
color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') +
|
||||||
|
Math.round(rgb.g * brightness).toString(16).padStart(2, '0') +
|
||||||
|
Math.round(rgb.b * brightness).toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
frame[toIndex(col, row, this.width)] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Spiral Bloom',
|
||||||
|
description: 'Rotating spiral patterns blooming outward',
|
||||||
|
parameters: {
|
||||||
|
rotationSpeed: { type: 'range', min: 0.3, max: 1.5, step: 0.1, default: 0.7 },
|
||||||
|
hueSpeed: { type: 'range', min: 0.1, max: 0.5, step: 0.05, default: 0.2 },
|
||||||
|
spiralArms: { type: 'range', min: 3, max: 8, step: 1, default: 5 },
|
||||||
|
brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SpiralBloomPreset;
|
||||||
141
presets/voxel-fireflies-preset.js
Normal file
141
presets/voxel-fireflies-preset.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Voxel Fireflies preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, fadeFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp, addHexColor } = require('./frame-utils');
|
||||||
|
|
||||||
|
class VoxelFirefliesPreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.fireflies = [];
|
||||||
|
this.paletteStops = [
|
||||||
|
{ stop: 0.0, color: hexToRgb('02030a') },
|
||||||
|
{ stop: 0.2, color: hexToRgb('031c2d') },
|
||||||
|
{ stop: 0.4, color: hexToRgb('053d4a') },
|
||||||
|
{ stop: 0.6, color: hexToRgb('107b68') },
|
||||||
|
{ stop: 0.8, color: hexToRgb('14c491') },
|
||||||
|
{ stop: 1.0, color: hexToRgb('f2ffd2') },
|
||||||
|
];
|
||||||
|
this.defaultParameters = {
|
||||||
|
fireflyCount: 18,
|
||||||
|
hoverSpeed: 0.6,
|
||||||
|
glowSpeed: 1.8,
|
||||||
|
trailDecay: 0.8,
|
||||||
|
brightness: 1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init();
|
||||||
|
this.fireflies = this.createFireflies(this.getParameter('fireflyCount') || 18, this.width, this.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
createFireflies(count, matrixWidth, matrixHeight) {
|
||||||
|
const list = [];
|
||||||
|
for (let index = 0; index < count; ++index) {
|
||||||
|
list.push(this.spawnFirefly(matrixWidth, matrixHeight));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnFirefly(matrixWidth, matrixHeight) {
|
||||||
|
return {
|
||||||
|
x: Math.random() * (matrixWidth - 1),
|
||||||
|
y: Math.random() * (matrixHeight - 1),
|
||||||
|
targetX: Math.random() * (matrixWidth - 1),
|
||||||
|
targetY: Math.random() * (matrixHeight - 1),
|
||||||
|
glowPhase: Math.random() * Math.PI * 2,
|
||||||
|
dwell: 1 + Math.random() * 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFirefly(firefly, deltaSeconds) {
|
||||||
|
const dx = firefly.targetX - firefly.x;
|
||||||
|
const dy = firefly.targetY - firefly.y;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
if (distance < 0.2) {
|
||||||
|
firefly.dwell -= deltaSeconds;
|
||||||
|
if (firefly.dwell <= 0) {
|
||||||
|
firefly.targetX = Math.random() * (this.width - 1);
|
||||||
|
firefly.targetY = Math.random() * (this.height - 1);
|
||||||
|
firefly.dwell = 1 + Math.random() * 2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const speed = (this.getParameter('hoverSpeed') || 0.6) * (0.8 + Math.random() * 0.4);
|
||||||
|
firefly.x += (dx / distance) * speed * deltaSeconds;
|
||||||
|
firefly.y += (dy / distance) * speed * deltaSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
firefly.glowPhase += deltaSeconds * (this.getParameter('glowSpeed') || 1.8) * (0.7 + Math.random() * 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFirefly(firefly) {
|
||||||
|
const baseGlow = (Math.sin(firefly.glowPhase) + 1) * 0.5;
|
||||||
|
const col = Math.round(firefly.x);
|
||||||
|
const row = Math.round(firefly.y);
|
||||||
|
|
||||||
|
for (let dy = -1; dy <= 1; ++dy) {
|
||||||
|
for (let dx = -1; dx <= 1; ++dx) {
|
||||||
|
const sampleX = col + dx;
|
||||||
|
const sampleY = row + dy;
|
||||||
|
if (sampleX < 0 || sampleX >= this.width || sampleY < 0 || sampleY >= this.height) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
const falloff = clamp(1 - distance * 0.7, 0, 1);
|
||||||
|
const intensity = baseGlow * falloff;
|
||||||
|
if (intensity <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.frame[toIndex(sampleX, sampleY, this.width)] = samplePalette(this.paletteStops, intensity);
|
||||||
|
if (distance === 0) {
|
||||||
|
addHexColor(this.frame, toIndex(sampleX, sampleY, this.width), 'ffd966', intensity * 1.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
this.frame = createFrame(this.width, this.height);
|
||||||
|
const trailDecay = this.getParameter('trailDecay') || 0.8;
|
||||||
|
const fireflyCount = this.getParameter('fireflyCount') || 18;
|
||||||
|
const brightness = this.getParameter('brightness') || 1.0;
|
||||||
|
|
||||||
|
fadeFrame(this.frame, trailDecay);
|
||||||
|
|
||||||
|
// Update firefly count if it changed
|
||||||
|
while (this.fireflies.length < fireflyCount) {
|
||||||
|
this.fireflies.push(this.spawnFirefly(this.width, this.height));
|
||||||
|
}
|
||||||
|
while (this.fireflies.length > fireflyCount) {
|
||||||
|
this.fireflies.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fireflies.forEach((firefly) => {
|
||||||
|
this.updateFirefly(firefly, 0.016); // Assume 60 FPS
|
||||||
|
this.drawFirefly(firefly);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Voxel Fireflies',
|
||||||
|
description: 'Glowing fireflies that hover and move around',
|
||||||
|
parameters: {
|
||||||
|
fireflyCount: { type: 'range', min: 8, max: 30, step: 2, default: 18 },
|
||||||
|
hoverSpeed: { type: 'range', min: 0.3, max: 1.2, step: 0.1, default: 0.6 },
|
||||||
|
glowSpeed: { type: 'range', min: 1.0, max: 3.0, step: 0.2, default: 1.8 },
|
||||||
|
trailDecay: { type: 'range', min: 0.7, max: 0.95, step: 0.05, default: 0.8 },
|
||||||
|
brightness: { type: 'range', min: 0.5, max: 1.5, step: 0.1, default: 1.0 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = VoxelFirefliesPreset;
|
||||||
90
presets/wormhole-tunnel-preset.js
Normal file
90
presets/wormhole-tunnel-preset.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Wormhole Tunnel preset for LEDLab
|
||||||
|
|
||||||
|
const BasePreset = require('./base-preset');
|
||||||
|
const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils');
|
||||||
|
|
||||||
|
class WormholeTunnelPreset extends BasePreset {
|
||||||
|
constructor(width = 16, height = 16) {
|
||||||
|
super(width, height);
|
||||||
|
this.timeSeconds = 0;
|
||||||
|
this.paletteStops = [
|
||||||
|
{ stop: 0.0, color: hexToRgb('010005') },
|
||||||
|
{ stop: 0.2, color: hexToRgb('07204f') },
|
||||||
|
{ stop: 0.45, color: hexToRgb('124aa0') },
|
||||||
|
{ stop: 0.7, color: hexToRgb('36a5ff') },
|
||||||
|
{ stop: 0.87, color: hexToRgb('99e6ff') },
|
||||||
|
{ stop: 1.0, color: hexToRgb('f1fbff') },
|
||||||
|
];
|
||||||
|
this.defaultParameters = {
|
||||||
|
ringDensity: 8,
|
||||||
|
ringSpeed: 1.4,
|
||||||
|
ringSharpness: 7.5,
|
||||||
|
twistIntensity: 2.2,
|
||||||
|
twistSpeed: 0.9,
|
||||||
|
coreExponent: 1.6,
|
||||||
|
brightness: 1.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
this.timeSeconds += 0.016; // Assume 60 FPS
|
||||||
|
|
||||||
|
const frame = createFrame(this.width, this.height);
|
||||||
|
const brightness = this.getParameter('brightness') || 1.0;
|
||||||
|
|
||||||
|
const cx = (this.width - 1) / 2;
|
||||||
|
const cy = (this.height - 1) / 2;
|
||||||
|
const radiusNorm = Math.hypot(cx, cy) || 1;
|
||||||
|
|
||||||
|
for (let row = 0; row < this.height; ++row) {
|
||||||
|
for (let col = 0; col < this.width; ++col) {
|
||||||
|
const dx = col - cx;
|
||||||
|
const dy = row - cy;
|
||||||
|
const radius = Math.hypot(dx, dy) / radiusNorm;
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
|
||||||
|
const radialPhase = radius * (this.getParameter('ringDensity') || 8) - this.timeSeconds * (this.getParameter('ringSpeed') || 1.4);
|
||||||
|
const ring = Math.exp(-Math.pow(Math.sin(radialPhase * Math.PI), 2) * (this.getParameter('ringSharpness') || 7.5));
|
||||||
|
|
||||||
|
const twist = Math.sin(angle * (this.getParameter('twistIntensity') || 2.2) + this.timeSeconds * (this.getParameter('twistSpeed') || 0.9)) * 0.35 + 0.65;
|
||||||
|
const depth = Math.pow(clamp(1 - radius, 0, 1), this.getParameter('coreExponent') || 1.6);
|
||||||
|
|
||||||
|
const value = clamp(ring * 0.6 + depth * 0.3 + twist * 0.1, 0, 1);
|
||||||
|
|
||||||
|
let color = samplePalette(this.paletteStops, value);
|
||||||
|
|
||||||
|
// Apply brightness
|
||||||
|
if (brightness < 1.0) {
|
||||||
|
const rgb = hexToRgb(color);
|
||||||
|
color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') +
|
||||||
|
Math.round(rgb.g * brightness).toString(16).padStart(2, '0') +
|
||||||
|
Math.round(rgb.b * brightness).toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
frame[toIndex(col, row, this.width)] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadata() {
|
||||||
|
return {
|
||||||
|
name: 'Wormhole Tunnel',
|
||||||
|
description: 'Hypnotic tunnel effect with rotating rings',
|
||||||
|
parameters: {
|
||||||
|
ringDensity: { type: 'range', min: 4, max: 12, step: 1, default: 8 },
|
||||||
|
ringSpeed: { type: 'range', min: 0.5, max: 2.5, step: 0.1, default: 1.4 },
|
||||||
|
ringSharpness: { type: 'range', min: 3, max: 12, step: 0.5, default: 7.5 },
|
||||||
|
twistIntensity: { type: 'range', min: 1.0, max: 4.0, step: 0.2, default: 2.2 },
|
||||||
|
twistSpeed: { type: 'range', min: 0.3, max: 1.5, step: 0.1, default: 0.9 },
|
||||||
|
coreExponent: { type: 'range', min: 1.0, max: 2.5, step: 0.1, default: 1.6 },
|
||||||
|
brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 },
|
||||||
|
},
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = WormholeTunnelPreset;
|
||||||
100
public/index.html
Normal file
100
public/index.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SPORE LEDLab</title>
|
||||||
|
<link rel="stylesheet" href="styles/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="ledlab-header">
|
||||||
|
<h1 class="ledlab-title">SPORE LEDLab</h1>
|
||||||
|
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="ledlab-main">
|
||||||
|
<!-- Matrix Display Section -->
|
||||||
|
<section class="matrix-section">
|
||||||
|
<div class="matrix-header">
|
||||||
|
<h2 class="matrix-title">Matrix Display</h2>
|
||||||
|
<div class="matrix-info">
|
||||||
|
<span id="matrix-size">16x16</span> |
|
||||||
|
<span id="streaming-status" class="status-indicator status-disconnected">Stopped</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="matrix-container">
|
||||||
|
<canvas class="matrix-canvas" id="matrix-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Control Panel Section -->
|
||||||
|
<section class="control-section">
|
||||||
|
<!-- Node Discovery -->
|
||||||
|
<div class="control-group">
|
||||||
|
<h3 class="control-group-title">SPORE Nodes</h3>
|
||||||
|
<div class="node-controls">
|
||||||
|
<button class="btn btn-secondary" id="broadcast-btn">Broadcast to All</button>
|
||||||
|
</div>
|
||||||
|
<div class="node-list" id="node-list">
|
||||||
|
<div class="loading">Discovering nodes...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Selection -->
|
||||||
|
<div class="control-group">
|
||||||
|
<h3 class="control-group-title">Animation Presets</h3>
|
||||||
|
<select class="preset-select" id="preset-select">
|
||||||
|
<option value="">Select a preset...</option>
|
||||||
|
</select>
|
||||||
|
<div class="preset-controls" id="preset-controls">
|
||||||
|
<!-- Dynamic controls will be inserted here -->
|
||||||
|
</div>
|
||||||
|
<div class="btn-container">
|
||||||
|
<button class="btn" id="start-btn">Start Streaming</button>
|
||||||
|
<button class="btn btn-secondary" id="stop-btn" disabled>Stop Streaming</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Matrix Configuration -->
|
||||||
|
<div class="control-group">
|
||||||
|
<h3 class="control-group-title">Matrix Configuration</h3>
|
||||||
|
<div class="matrix-config">
|
||||||
|
<div class="matrix-input">
|
||||||
|
<label for="matrix-width">Width</label>
|
||||||
|
<input type="number" id="matrix-width" min="1" max="32" value="16">
|
||||||
|
</div>
|
||||||
|
<div class="matrix-input">
|
||||||
|
<label for="matrix-height">Height</label>
|
||||||
|
<input type="number" id="matrix-height" min="1" max="32" value="16">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-small" id="apply-matrix-btn">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Control -->
|
||||||
|
<div class="control-group">
|
||||||
|
<h3 class="control-group-title">Manual Control</h3>
|
||||||
|
<div class="btn-container">
|
||||||
|
<button class="btn btn-secondary" id="send-test-btn">Send Test Frame</button>
|
||||||
|
<button class="btn btn-secondary" id="clear-matrix-btn">Clear Matrix</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load JavaScript files -->
|
||||||
|
<script src="scripts/constants.js"></script>
|
||||||
|
<script src="scripts/theme-manager.js"></script>
|
||||||
|
<script src="scripts/framework.js"></script>
|
||||||
|
<script src="scripts/matrix-display.js"></script>
|
||||||
|
<script src="scripts/preset-controls.js"></script>
|
||||||
|
<script src="scripts/node-discovery.js"></script>
|
||||||
|
<script src="scripts/ledlab-app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
51
public/scripts/constants.js
Normal file
51
public/scripts/constants.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
(function(){
|
||||||
|
const TIMING = {
|
||||||
|
NAV_COOLDOWN_MS: 300,
|
||||||
|
VIEW_FADE_OUT_MS: 150,
|
||||||
|
VIEW_FADE_IN_MS: 200,
|
||||||
|
VIEW_FADE_DELAY_MS: 50,
|
||||||
|
AUTO_REFRESH_MS: 30000,
|
||||||
|
PRIMARY_NODE_REFRESH_MS: 10000,
|
||||||
|
LOAD_GUARD_MS: 10000,
|
||||||
|
UDP_DISCOVERY_INTERVAL_MS: 5000,
|
||||||
|
WEBSOCKET_PING_INTERVAL_MS: 30000,
|
||||||
|
FRAME_UPDATE_INTERVAL_MS: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
const SELECTORS = {
|
||||||
|
NAV_TAB: '.nav-tab',
|
||||||
|
VIEW_CONTENT: '.view-content',
|
||||||
|
MATRIX_CANVAS: '.matrix-canvas',
|
||||||
|
PRESET_SELECT: '.preset-select',
|
||||||
|
NODE_LIST: '.node-list',
|
||||||
|
CONTROL_PANEL: '.control-panel'
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLASSES = {
|
||||||
|
NODE_CONNECTED: 'node-connected',
|
||||||
|
NODE_DISCONNECTED: 'node-disconnected',
|
||||||
|
NODE_SELECTED: 'node-selected',
|
||||||
|
PRESET_ACTIVE: 'preset-active',
|
||||||
|
STREAMING_ACTIVE: 'streaming-active'
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
MATRIX_WIDTH: 16,
|
||||||
|
MATRIX_HEIGHT: 16,
|
||||||
|
UDP_PORT: 4210,
|
||||||
|
WEBSOCKET_PORT: 8080,
|
||||||
|
BROADCAST_ADDRESS: '255.255.255.255'
|
||||||
|
};
|
||||||
|
|
||||||
|
const MESSAGES = {
|
||||||
|
NODE_DISCOVERED: 'node:discovered',
|
||||||
|
NODE_LOST: 'node:lost',
|
||||||
|
STREAM_START: 'stream:start',
|
||||||
|
STREAM_STOP: 'stream:stop',
|
||||||
|
STREAM_UPDATE: 'stream:update',
|
||||||
|
PRESET_CHANGED: 'preset:changed',
|
||||||
|
MATRIX_UPDATED: 'matrix:updated'
|
||||||
|
};
|
||||||
|
|
||||||
|
window.CONSTANTS = window.CONSTANTS || { TIMING, SELECTORS, CLASSES, DEFAULTS, MESSAGES };
|
||||||
|
})();
|
||||||
759
public/scripts/framework.js
Normal file
759
public/scripts/framework.js
Normal file
@@ -0,0 +1,759 @@
|
|||||||
|
// SPORE LEDLab Framework - Component-based architecture with pub/sub system
|
||||||
|
|
||||||
|
// Lightweight logger with level gating
|
||||||
|
const logger = {
|
||||||
|
debug: (...args) => { try { if (window && window.DEBUG) { console.debug(...args); } } catch (_) { /* no-op */ } },
|
||||||
|
info: (...args) => console.info(...args),
|
||||||
|
warn: (...args) => console.warn(...args),
|
||||||
|
error: (...args) => console.error(...args),
|
||||||
|
};
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.logger = window.logger || logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Bus for pub/sub communication
|
||||||
|
class EventBus {
|
||||||
|
constructor() {
|
||||||
|
this.events = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to an event
|
||||||
|
subscribe(event, callback) {
|
||||||
|
if (!this.events.has(event)) {
|
||||||
|
this.events.set(event, []);
|
||||||
|
}
|
||||||
|
this.events.get(event).push(callback);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
const callbacks = this.events.get(event);
|
||||||
|
if (callbacks) {
|
||||||
|
const index = callbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
callbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish an event
|
||||||
|
publish(event, data) {
|
||||||
|
if (this.events.has(event)) {
|
||||||
|
this.events.get(event).forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in event callback for ${event}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe from an event
|
||||||
|
unsubscribe(event, callback) {
|
||||||
|
if (this.events.has(event)) {
|
||||||
|
const callbacks = this.events.get(event);
|
||||||
|
const index = callbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
callbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all events
|
||||||
|
clear() {
|
||||||
|
this.events.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base ViewModel class with enhanced state management
|
||||||
|
class ViewModel {
|
||||||
|
constructor() {
|
||||||
|
this._data = {};
|
||||||
|
this._listeners = new Map();
|
||||||
|
this._eventBus = null;
|
||||||
|
this._uiState = new Map(); // Store UI state like active tabs, expanded cards, etc.
|
||||||
|
this._previousData = {}; // Store previous data for change detection
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the event bus for this view model
|
||||||
|
setEventBus(eventBus) {
|
||||||
|
this._eventBus = eventBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get data property
|
||||||
|
get(property) {
|
||||||
|
return this._data[property];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set data property and notify listeners
|
||||||
|
set(property, value) {
|
||||||
|
logger.debug(`ViewModel: Setting property '${property}' to:`, value);
|
||||||
|
|
||||||
|
// Check if the value has actually changed
|
||||||
|
const hasChanged = this._data[property] !== value;
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
// Store previous value for change detection
|
||||||
|
this._previousData[property] = this._data[property];
|
||||||
|
|
||||||
|
// Update the data
|
||||||
|
this._data[property] = value;
|
||||||
|
|
||||||
|
logger.debug(`ViewModel: Property '${property}' changed, notifying listeners...`);
|
||||||
|
this._notifyListeners(property, value, this._previousData[property]);
|
||||||
|
} else {
|
||||||
|
logger.debug(`ViewModel: Property '${property}' unchanged, skipping notification`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set multiple properties at once with change detection
|
||||||
|
setMultiple(properties) {
|
||||||
|
const changedProperties = {};
|
||||||
|
|
||||||
|
// Determine changes and update previousData snapshot per key
|
||||||
|
Object.keys(properties).forEach(key => {
|
||||||
|
const newValue = properties[key];
|
||||||
|
const oldValue = this._data[key];
|
||||||
|
if (oldValue !== newValue) {
|
||||||
|
this._previousData[key] = oldValue;
|
||||||
|
changedProperties[key] = newValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply all properties
|
||||||
|
Object.keys(properties).forEach(key => {
|
||||||
|
this._data[key] = properties[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify listeners only for changed properties with accurate previous values
|
||||||
|
Object.keys(changedProperties).forEach(key => {
|
||||||
|
this._notifyListeners(key, this._data[key], this._previousData[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(changedProperties).length > 0) {
|
||||||
|
logger.debug(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to property changes
|
||||||
|
subscribe(property, callback) {
|
||||||
|
if (!this._listeners.has(property)) {
|
||||||
|
this._listeners.set(property, []);
|
||||||
|
}
|
||||||
|
this._listeners.get(property).push(callback);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
const callbacks = this._listeners.get(property);
|
||||||
|
if (callbacks) {
|
||||||
|
const index = callbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
callbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify listeners of property changes
|
||||||
|
_notifyListeners(property, value, previousValue) {
|
||||||
|
logger.debug(`ViewModel: _notifyListeners called for property '${property}'`);
|
||||||
|
if (this._listeners.has(property)) {
|
||||||
|
const callbacks = this._listeners.get(property);
|
||||||
|
logger.debug(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`);
|
||||||
|
callbacks.forEach((callback, index) => {
|
||||||
|
try {
|
||||||
|
logger.debug(`ViewModel: Calling listener ${index} for property '${property}'`);
|
||||||
|
callback(value, previousValue);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in property listener for ${property}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.debug(`ViewModel: No listeners found for property '${property}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish event to event bus
|
||||||
|
publish(event, data) {
|
||||||
|
if (this._eventBus) {
|
||||||
|
this._eventBus.publish(event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all data
|
||||||
|
getAll() {
|
||||||
|
return { ...this._data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all data
|
||||||
|
clear() {
|
||||||
|
this._data = {};
|
||||||
|
this._listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI State Management Methods
|
||||||
|
setUIState(key, value) {
|
||||||
|
this._uiState.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUIState(key) {
|
||||||
|
return this._uiState.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllUIState() {
|
||||||
|
return new Map(this._uiState);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUIState(key) {
|
||||||
|
if (key) {
|
||||||
|
this._uiState.delete(key);
|
||||||
|
} else {
|
||||||
|
this._uiState.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a property has changed
|
||||||
|
hasChanged(property) {
|
||||||
|
return this._data[property] !== this._previousData[property];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get previous value of a property
|
||||||
|
getPrevious(property) {
|
||||||
|
return this._previousData[property];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch update with change detection
|
||||||
|
batchUpdate(updates, options = {}) {
|
||||||
|
const { notifyChanges = true } = options;
|
||||||
|
|
||||||
|
// Track which keys actually change and what the previous values were
|
||||||
|
const changedKeys = [];
|
||||||
|
Object.keys(updates).forEach(key => {
|
||||||
|
const newValue = updates[key];
|
||||||
|
const oldValue = this._data[key];
|
||||||
|
if (oldValue !== newValue) {
|
||||||
|
this._previousData[key] = oldValue;
|
||||||
|
this._data[key] = newValue;
|
||||||
|
changedKeys.push(key);
|
||||||
|
} else {
|
||||||
|
// Still apply to ensure consistency if needed
|
||||||
|
this._data[key] = newValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify listeners for changed keys
|
||||||
|
if (notifyChanges) {
|
||||||
|
changedKeys.forEach(key => {
|
||||||
|
this._notifyListeners(key, this._data[key], this._previousData[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base Component class
|
||||||
|
class Component {
|
||||||
|
constructor(container, viewModel, eventBus) {
|
||||||
|
this.container = container;
|
||||||
|
this.viewModel = viewModel;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
this.isMounted = false;
|
||||||
|
this.unsubscribers = [];
|
||||||
|
this.uiState = new Map(); // Local UI state for this component
|
||||||
|
|
||||||
|
// Set event bus on view model
|
||||||
|
if (this.viewModel) {
|
||||||
|
this.viewModel.setEventBus(eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind methods
|
||||||
|
this.render = this.render.bind(this);
|
||||||
|
this.mount = this.mount.bind(this);
|
||||||
|
this.unmount = this.unmount.bind(this);
|
||||||
|
this.updatePartial = this.updatePartial.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount the component
|
||||||
|
mount() {
|
||||||
|
if (this.isMounted) return;
|
||||||
|
|
||||||
|
logger.debug(`${this.constructor.name}: Starting mount...`);
|
||||||
|
this.isMounted = true;
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupViewModelListeners();
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
logger.debug(`${this.constructor.name}: Mounted successfully`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmount the component
|
||||||
|
unmount() {
|
||||||
|
if (!this.isMounted) return;
|
||||||
|
|
||||||
|
this.isMounted = false;
|
||||||
|
this.cleanupEventListeners();
|
||||||
|
this.cleanupViewModelListeners();
|
||||||
|
|
||||||
|
logger.debug(`${this.constructor.name} unmounted`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup event listeners (override in subclasses)
|
||||||
|
setupEventListeners() {
|
||||||
|
// Override in subclasses
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup view model listeners (override in subclasses)
|
||||||
|
setupViewModelListeners() {
|
||||||
|
// Override in subclasses
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup event listeners (override in subclasses)
|
||||||
|
cleanupEventListeners() {
|
||||||
|
// Override in subclasses
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup view model listeners (override in subclasses)
|
||||||
|
cleanupViewModelListeners() {
|
||||||
|
// Override in subclasses
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the component (override in subclasses)
|
||||||
|
render() {
|
||||||
|
// Override in subclasses
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial update method for efficient data updates
|
||||||
|
updatePartial(property, newValue, previousValue) {
|
||||||
|
// Override in subclasses to implement partial updates
|
||||||
|
logger.debug(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI State Management Methods
|
||||||
|
setUIState(key, value) {
|
||||||
|
this.uiState.set(key, value);
|
||||||
|
// Also store in view model for persistence across refreshes
|
||||||
|
if (this.viewModel) {
|
||||||
|
this.viewModel.setUIState(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getUIState(key) {
|
||||||
|
// First try local state, then view model state
|
||||||
|
return this.uiState.get(key) || (this.viewModel ? this.viewModel.getUIState(key) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllUIState() {
|
||||||
|
const localState = new Map(this.uiState);
|
||||||
|
const viewModelState = this.viewModel ? this.viewModel.getAllUIState() : new Map();
|
||||||
|
|
||||||
|
// Merge states, with local state taking precedence
|
||||||
|
const mergedState = new Map(viewModelState);
|
||||||
|
localState.forEach((value, key) => mergedState.set(key, value));
|
||||||
|
|
||||||
|
return mergedState;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUIState(key) {
|
||||||
|
if (key) {
|
||||||
|
this.uiState.delete(key);
|
||||||
|
if (this.viewModel) {
|
||||||
|
this.viewModel.clearUIState(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.uiState.clear();
|
||||||
|
if (this.viewModel) {
|
||||||
|
this.viewModel.clearUIState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to add event listener and track for cleanup
|
||||||
|
addEventListener(element, event, handler) {
|
||||||
|
element.addEventListener(event, handler);
|
||||||
|
this.unsubscribers.push(() => {
|
||||||
|
element.removeEventListener(event, handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to subscribe to event bus and track for cleanup
|
||||||
|
subscribeToEvent(event, handler) {
|
||||||
|
const unsubscribe = this.eventBus.subscribe(event, handler);
|
||||||
|
this.unsubscribers.push(unsubscribe);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to subscribe to view model property and track for cleanup
|
||||||
|
subscribeToProperty(property, handler) {
|
||||||
|
if (this.viewModel) {
|
||||||
|
const unsubscribe = this.viewModel.subscribe(property, (newValue, previousValue) => {
|
||||||
|
// Call handler with both new and previous values for change detection
|
||||||
|
handler(newValue, previousValue);
|
||||||
|
});
|
||||||
|
this.unsubscribers.push(unsubscribe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to find element within component container
|
||||||
|
findElement(selector) {
|
||||||
|
return this.container.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to find all elements within component container
|
||||||
|
findAllElements(selector) {
|
||||||
|
return this.container.querySelectorAll(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to set innerHTML safely
|
||||||
|
setHTML(selector, html) {
|
||||||
|
logger.debug(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`);
|
||||||
|
|
||||||
|
let element;
|
||||||
|
if (selector === '') {
|
||||||
|
// Empty selector means set HTML on the component's container itself
|
||||||
|
element = this.container;
|
||||||
|
logger.debug(`${this.constructor.name}: Using component container for empty selector`);
|
||||||
|
} else {
|
||||||
|
// Find element within the component's container
|
||||||
|
element = this.findElement(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
logger.debug(`${this.constructor.name}: Element found, setting innerHTML`);
|
||||||
|
element.innerHTML = html;
|
||||||
|
logger.debug(`${this.constructor.name}: innerHTML set successfully`);
|
||||||
|
} else {
|
||||||
|
console.error(`${this.constructor.name}: Element not found for selector '${selector}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to set text content safely
|
||||||
|
setText(selector, text) {
|
||||||
|
const element = this.findElement(selector);
|
||||||
|
if (element) {
|
||||||
|
element.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to add/remove CSS classes
|
||||||
|
setClass(selector, className, add = true) {
|
||||||
|
const element = this.findElement(selector);
|
||||||
|
if (element) {
|
||||||
|
if (add) {
|
||||||
|
element.classList.add(className);
|
||||||
|
} else {
|
||||||
|
element.classList.remove(className);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to set CSS styles
|
||||||
|
setStyle(selector, property, value) {
|
||||||
|
const element = this.findElement(selector);
|
||||||
|
if (element) {
|
||||||
|
element.style[property] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to show/hide elements
|
||||||
|
setVisible(selector, visible) {
|
||||||
|
const element = this.findElement(selector);
|
||||||
|
if (element) {
|
||||||
|
element.style.display = visible ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to enable/disable elements
|
||||||
|
setEnabled(selector, enabled) {
|
||||||
|
const element = this.findElement(selector);
|
||||||
|
if (element) {
|
||||||
|
element.disabled = !enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusable render helpers
|
||||||
|
renderLoading(customHtml) {
|
||||||
|
const html = customHtml || `
|
||||||
|
<div class="loading">
|
||||||
|
<div>Loading...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.setHTML('', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderError(message) {
|
||||||
|
const safe = this.escapeHtml(String(message || 'An error occurred'));
|
||||||
|
const html = `
|
||||||
|
<div class="error">
|
||||||
|
<strong>Error:</strong><br>
|
||||||
|
${safe}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.setHTML('', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEmpty(customHtml) {
|
||||||
|
const html = customHtml || `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div>No data</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.setHTML('', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic HTML escaping for dynamic values
|
||||||
|
escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application class to manage components and routing
|
||||||
|
class App {
|
||||||
|
constructor() {
|
||||||
|
this.eventBus = new EventBus();
|
||||||
|
this.components = new Map();
|
||||||
|
this.currentView = null;
|
||||||
|
this.routes = new Map();
|
||||||
|
this.navigationInProgress = false;
|
||||||
|
this.navigationQueue = [];
|
||||||
|
this.lastNavigationTime = 0;
|
||||||
|
this.navigationCooldown = (window.CONSTANTS && window.CONSTANTS.TIMING.NAV_COOLDOWN_MS) || 300;
|
||||||
|
|
||||||
|
// Component cache to keep components alive
|
||||||
|
this.componentCache = new Map();
|
||||||
|
this.cachedViews = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a route
|
||||||
|
registerRoute(name, componentClass, containerId, viewModel = null) {
|
||||||
|
this.routes.set(name, { componentClass, containerId, viewModel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to a route
|
||||||
|
navigateTo(routeName) {
|
||||||
|
// Check cooldown period
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastNavigationTime < this.navigationCooldown) {
|
||||||
|
logger.debug(`App: Navigation cooldown active, skipping route '${routeName}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If navigation is already in progress, queue this request
|
||||||
|
if (this.navigationInProgress) {
|
||||||
|
logger.debug(`App: Navigation in progress, queuing route '${routeName}'`);
|
||||||
|
if (!this.navigationQueue.includes(routeName)) {
|
||||||
|
this.navigationQueue.push(routeName);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If trying to navigate to the same route, do nothing
|
||||||
|
if (this.currentView && this.currentView.routeName === routeName) {
|
||||||
|
logger.debug(`App: Already on route '${routeName}', skipping navigation`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastNavigationTime = now;
|
||||||
|
this.performNavigation(routeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the actual navigation
|
||||||
|
async performNavigation(routeName) {
|
||||||
|
this.navigationInProgress = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug(`App: Navigating to route '${routeName}'`);
|
||||||
|
const route = this.routes.get(routeName);
|
||||||
|
if (!route) {
|
||||||
|
console.error(`Route '${routeName}' not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`);
|
||||||
|
|
||||||
|
// Get or create component from cache
|
||||||
|
let component = this.componentCache.get(routeName);
|
||||||
|
if (!component) {
|
||||||
|
logger.debug(`App: Component not in cache, creating new instance for '${routeName}'`);
|
||||||
|
const container = document.getElementById(route.containerId);
|
||||||
|
if (!container) {
|
||||||
|
console.error(`Container '${route.containerId}' not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
component = new route.componentClass(container, route.viewModel, this.eventBus);
|
||||||
|
component.routeName = routeName;
|
||||||
|
component.isCached = true;
|
||||||
|
this.componentCache.set(routeName, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide current view smoothly
|
||||||
|
if (this.currentView) {
|
||||||
|
logger.debug('App: Hiding current view');
|
||||||
|
await this.hideCurrentView();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show new view
|
||||||
|
logger.debug(`App: Showing new view '${routeName}'`);
|
||||||
|
await this.showView(routeName, component);
|
||||||
|
|
||||||
|
// Update navigation state
|
||||||
|
this.updateNavigation(routeName);
|
||||||
|
|
||||||
|
// Set as current view
|
||||||
|
this.currentView = component;
|
||||||
|
|
||||||
|
// Mark view as cached for future use
|
||||||
|
this.cachedViews.add(routeName);
|
||||||
|
|
||||||
|
logger.debug(`App: Navigation to '${routeName}' completed`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('App: Navigation failed:', error);
|
||||||
|
} finally {
|
||||||
|
this.navigationInProgress = false;
|
||||||
|
|
||||||
|
// Process any queued navigation requests
|
||||||
|
if (this.navigationQueue.length > 0) {
|
||||||
|
const nextRoute = this.navigationQueue.shift();
|
||||||
|
logger.debug(`App: Processing queued navigation to '${nextRoute}'`);
|
||||||
|
setTimeout(() => this.navigateTo(nextRoute), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide current view smoothly
|
||||||
|
async hideCurrentView() {
|
||||||
|
if (!this.currentView) return;
|
||||||
|
|
||||||
|
// If component is mounted, pause it instead of unmounting
|
||||||
|
if (this.currentView.isMounted) {
|
||||||
|
logger.debug('App: Pausing current view instead of unmounting');
|
||||||
|
this.currentView.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade out the container
|
||||||
|
if (this.currentView.container) {
|
||||||
|
this.currentView.container.style.opacity = '0';
|
||||||
|
this.currentView.container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150}ms ease-out`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for fade out to complete
|
||||||
|
await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show view smoothly
|
||||||
|
async showView(routeName, component) {
|
||||||
|
const container = component.container;
|
||||||
|
|
||||||
|
// Ensure component is mounted (but not necessarily active); lazy-create now if needed
|
||||||
|
if (!component) {
|
||||||
|
const route = this.routes.get(routeName);
|
||||||
|
const container = document.getElementById(route.containerId);
|
||||||
|
component = new route.componentClass(container, route.viewModel, this.eventBus);
|
||||||
|
component.routeName = routeName;
|
||||||
|
component.isCached = true;
|
||||||
|
this.componentCache.set(routeName, component);
|
||||||
|
}
|
||||||
|
if (!component.isMounted) {
|
||||||
|
logger.debug(`App: Mounting component for '${routeName}'`);
|
||||||
|
component.mount();
|
||||||
|
} else {
|
||||||
|
logger.debug(`App: Resuming component for '${routeName}'`);
|
||||||
|
component.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade in the container
|
||||||
|
container.style.opacity = '0';
|
||||||
|
container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_IN_MS) || 200}ms ease-in`;
|
||||||
|
|
||||||
|
// Small delay to ensure smooth transition
|
||||||
|
await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_DELAY_MS) || 50));
|
||||||
|
|
||||||
|
// Fade in
|
||||||
|
container.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update navigation state
|
||||||
|
updateNavigation(activeRoute) {
|
||||||
|
// Remove active class from all nav tabs
|
||||||
|
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
|
||||||
|
tab.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active class to current route tab
|
||||||
|
const activeTab = document.querySelector(`[data-view="${activeRoute}"]`);
|
||||||
|
if (activeTab) {
|
||||||
|
activeTab.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide all view contents with smooth transition
|
||||||
|
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.VIEW_CONTENT) || '.view-content').forEach(view => {
|
||||||
|
view.classList.remove('active');
|
||||||
|
view.style.opacity = '0';
|
||||||
|
view.style.transition = 'opacity 0.15s ease-out';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show current view content with smooth transition
|
||||||
|
const activeView = document.getElementById(`${activeRoute}-view`);
|
||||||
|
if (activeView) {
|
||||||
|
activeView.classList.add('active');
|
||||||
|
// Small delay to ensure smooth transition
|
||||||
|
setTimeout(() => {
|
||||||
|
activeView.style.opacity = '1';
|
||||||
|
activeView.style.transition = 'opacity 0.2s ease-in';
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a component
|
||||||
|
registerComponent(name, component) {
|
||||||
|
this.components.set(name, component);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a component by name
|
||||||
|
getComponent(name) {
|
||||||
|
return this.components.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the event bus
|
||||||
|
getEventBus() {
|
||||||
|
return this.eventBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the application
|
||||||
|
init() {
|
||||||
|
logger.debug('SPORE LEDLab Framework initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup navigation
|
||||||
|
setupNavigation() {
|
||||||
|
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const routeName = tab.dataset.view;
|
||||||
|
this.navigateTo(routeName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up cached components (call when app is shutting down)
|
||||||
|
cleanup() {
|
||||||
|
logger.debug('App: Cleaning up cached components...');
|
||||||
|
|
||||||
|
this.componentCache.forEach((component, routeName) => {
|
||||||
|
if (component.isMounted) {
|
||||||
|
logger.debug(`App: Unmounting cached component '${routeName}'`);
|
||||||
|
component.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.componentCache.clear();
|
||||||
|
this.cachedViews.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global app instance (disabled for LEDLab single-page app)
|
||||||
|
// window.app = new App();
|
||||||
245
public/scripts/ledlab-app.js
Normal file
245
public/scripts/ledlab-app.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
// LEDLab Main Application
|
||||||
|
|
||||||
|
class LEDLabApp {
|
||||||
|
constructor() {
|
||||||
|
this.viewModel = new ViewModel();
|
||||||
|
this.eventBus = new EventBus();
|
||||||
|
this.ws = null;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.maxReconnectAttempts = 5;
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
|
|
||||||
|
this.matrixDisplay = null;
|
||||||
|
this.presetControls = null;
|
||||||
|
this.nodeDiscovery = null;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Set up event bus on view model
|
||||||
|
this.viewModel.setEventBus(this.eventBus);
|
||||||
|
|
||||||
|
// Initialize components
|
||||||
|
this.initComponents();
|
||||||
|
|
||||||
|
// Set up WebSocket connection
|
||||||
|
this.connectWebSocket();
|
||||||
|
|
||||||
|
// Set up global event listeners
|
||||||
|
this.setupGlobalEventListeners();
|
||||||
|
|
||||||
|
console.log('LEDLab app initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
initComponents() {
|
||||||
|
// Initialize Matrix Display component
|
||||||
|
const matrixContainer = document.querySelector('.matrix-section');
|
||||||
|
if (matrixContainer) {
|
||||||
|
this.matrixDisplay = new MatrixDisplay(matrixContainer, this.viewModel, this.eventBus);
|
||||||
|
this.matrixDisplay.mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Preset Controls component
|
||||||
|
const controlsContainer = document.querySelector('.control-section');
|
||||||
|
if (controlsContainer) {
|
||||||
|
this.presetControls = new PresetControls(controlsContainer, this.viewModel, this.eventBus);
|
||||||
|
this.presetControls.mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Node Discovery component
|
||||||
|
const nodeContainer = document.querySelector('#node-list').parentElement;
|
||||||
|
if (nodeContainer) {
|
||||||
|
this.nodeDiscovery = new NodeDiscovery(nodeContainer, this.viewModel, this.eventBus);
|
||||||
|
this.nodeDiscovery.mount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
this.setupWebSocketEventHandlers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create WebSocket connection:', error);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupWebSocketEventHandlers() {
|
||||||
|
if (!this.ws) return;
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// Send any queued messages
|
||||||
|
this.flushMessageQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
this.handleWebSocketMessage(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = (event) => {
|
||||||
|
console.log('WebSocket disconnected:', event.code, event.reason);
|
||||||
|
|
||||||
|
if (event.code !== 1000) { // Not a normal closure
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWebSocketMessage(data) {
|
||||||
|
// Publish event to the event bus for components to handle
|
||||||
|
this.eventBus.publish(data.type, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect() {
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.error('Max reconnection attempts reached');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff
|
||||||
|
|
||||||
|
console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connectWebSocket();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendWebSocketMessage(data) {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
} else {
|
||||||
|
console.warn('WebSocket not connected, queuing message');
|
||||||
|
// Queue message for when connection is restored
|
||||||
|
if (!this.messageQueue) {
|
||||||
|
this.messageQueue = [];
|
||||||
|
}
|
||||||
|
this.messageQueue.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushMessageQueue() {
|
||||||
|
if (this.messageQueue && this.messageQueue.length > 0) {
|
||||||
|
console.log(`Flushing ${this.messageQueue.length} queued messages`);
|
||||||
|
this.messageQueue.forEach(data => this.sendWebSocketMessage(data));
|
||||||
|
this.messageQueue = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupGlobalEventListeners() {
|
||||||
|
// Listen for messages from components that need to be sent to server
|
||||||
|
this.eventBus.subscribe('startPreset', (data) => {
|
||||||
|
this.sendWebSocketMessage({
|
||||||
|
type: 'startPreset',
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.subscribe('stopStreaming', (data) => {
|
||||||
|
this.sendWebSocketMessage({
|
||||||
|
type: 'stopStreaming',
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.subscribe('updatePresetParameter', (data) => {
|
||||||
|
this.sendWebSocketMessage({
|
||||||
|
type: 'updatePresetParameter',
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.subscribe('setMatrixSize', (data) => {
|
||||||
|
this.sendWebSocketMessage({
|
||||||
|
type: 'setMatrixSize',
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.subscribe('sendToNode', (data) => {
|
||||||
|
this.sendWebSocketMessage({
|
||||||
|
type: 'sendToNode',
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.subscribe('broadcastToAll', (data) => {
|
||||||
|
this.sendWebSocketMessage({
|
||||||
|
type: 'broadcastToAll',
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.subscribe('selectNode', (data) => {
|
||||||
|
this.sendWebSocketMessage({
|
||||||
|
type: 'selectNode',
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventBus.subscribe('selectBroadcast', (data) => {
|
||||||
|
this.sendWebSocketMessage({
|
||||||
|
type: 'selectBroadcast',
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle theme changes
|
||||||
|
window.addEventListener('themeChanged', (event) => {
|
||||||
|
console.log('Theme changed to:', event.detail.theme);
|
||||||
|
// Update any theme-specific UI elements if needed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API methods for external use
|
||||||
|
startPreset(presetName, width, height) {
|
||||||
|
this.viewModel.publish('startPreset', { presetName, width, height });
|
||||||
|
}
|
||||||
|
|
||||||
|
stopStreaming() {
|
||||||
|
this.viewModel.publish('stopStreaming', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePresetParameter(parameter, value) {
|
||||||
|
this.viewModel.publish('updatePresetParameter', { parameter, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
setMatrixSize(width, height) {
|
||||||
|
this.viewModel.publish('setMatrixSize', { width, height });
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToNode(nodeIp, message) {
|
||||||
|
this.viewModel.publish('sendToNode', { nodeIp, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToAll(message) {
|
||||||
|
this.viewModel.publish('broadcastToAll', { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the app when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
window.ledlabApp = new LEDLabApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = LEDLabApp;
|
||||||
|
}
|
||||||
203
public/scripts/matrix-display.js
Normal file
203
public/scripts/matrix-display.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
// Matrix Display Component
|
||||||
|
|
||||||
|
class MatrixDisplay extends Component {
|
||||||
|
constructor(container, viewModel, eventBus) {
|
||||||
|
super(container, viewModel, eventBus);
|
||||||
|
this.canvas = null;
|
||||||
|
this.ctx = null;
|
||||||
|
this.pixelSize = 20;
|
||||||
|
this.matrixWidth = 16;
|
||||||
|
this.matrixHeight = 16;
|
||||||
|
this.frameData = null;
|
||||||
|
this.animationId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
super.mount();
|
||||||
|
this.setupCanvas();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupViewModelListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCanvas() {
|
||||||
|
this.canvas = this.findElement('#matrix-canvas');
|
||||||
|
if (!this.canvas) {
|
||||||
|
console.error('Matrix canvas element not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx = this.canvas.getContext('2d');
|
||||||
|
this.updateCanvasSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCanvasSize() {
|
||||||
|
if (!this.canvas || !this.ctx) return;
|
||||||
|
|
||||||
|
const container = this.canvas.parentElement;
|
||||||
|
const containerWidth = container.clientWidth - 32; // Account for padding
|
||||||
|
const containerHeight = container.clientHeight - 32;
|
||||||
|
|
||||||
|
// Calculate pixel size to fit the matrix in the container
|
||||||
|
const maxPixelWidth = Math.floor(containerWidth / this.matrixWidth);
|
||||||
|
const maxPixelHeight = Math.floor(containerHeight / this.matrixHeight);
|
||||||
|
this.pixelSize = Math.min(maxPixelWidth, maxPixelHeight, 40); // Cap at 40px
|
||||||
|
|
||||||
|
this.canvas.width = this.matrixWidth * this.pixelSize;
|
||||||
|
this.canvas.height = this.matrixHeight * this.pixelSize;
|
||||||
|
|
||||||
|
// Center the canvas
|
||||||
|
const canvasContainer = this.canvas.parentElement;
|
||||||
|
canvasContainer.style.display = 'flex';
|
||||||
|
canvasContainer.style.alignItems = 'center';
|
||||||
|
canvasContainer.style.justifyContent = 'center';
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Handle window resize
|
||||||
|
this.addEventListener(window, 'resize', () => {
|
||||||
|
this.updateCanvasSize();
|
||||||
|
this.renderFrame();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupViewModelListeners() {
|
||||||
|
this.subscribeToEvent('frame', (data) => {
|
||||||
|
this.frameData = data.data;
|
||||||
|
this.renderFrame();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscribeToEvent('matrixSizeChanged', (data) => {
|
||||||
|
this.matrixWidth = data.size.width;
|
||||||
|
this.matrixHeight = data.size.height;
|
||||||
|
this.updateCanvasSize();
|
||||||
|
this.updateMatrixSize(data.size.width, data.size.height);
|
||||||
|
this.renderFrame();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscribeToEvent('streamingStarted', (data) => {
|
||||||
|
this.updateStreamingStatus('streaming');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscribeToEvent('streamingStopped', () => {
|
||||||
|
this.updateStreamingStatus('stopped');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscribeToEvent('status', (data) => {
|
||||||
|
// Update UI to reflect current server state
|
||||||
|
this.updateStreamingStatus(data.data.streaming ? 'streaming' : 'stopped');
|
||||||
|
if (data.data.matrixSize) {
|
||||||
|
this.matrixWidth = data.data.matrixSize.width;
|
||||||
|
this.matrixHeight = data.data.matrixSize.height;
|
||||||
|
this.updateCanvasSize();
|
||||||
|
this.updateMatrixSize(data.data.matrixSize.width, data.data.matrixSize.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStreamingStatus(status) {
|
||||||
|
const statusElement = this.findElement('#streaming-status');
|
||||||
|
if (statusElement) {
|
||||||
|
statusElement.className = `status-indicator status-${status}`;
|
||||||
|
statusElement.textContent = status === 'streaming' ? 'Streaming' : 'Stopped';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
if (!this.ctx || !this.canvas) return;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
this.ctx.fillStyle = 'var(--matrix-bg)';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// Render pixels if we have frame data
|
||||||
|
if (this.frameData && this.frameData.startsWith('RAW:')) {
|
||||||
|
const pixelData = this.frameData.substring(4); // Remove 'RAW:' prefix
|
||||||
|
|
||||||
|
// Render pixels in serpentine order to match hardware layout
|
||||||
|
for (let row = 0; row < this.matrixHeight; row++) {
|
||||||
|
for (let col = 0; col < this.matrixWidth; col++) {
|
||||||
|
// Calculate serpentine index manually
|
||||||
|
const hardwareIndex = (row % 2 === 0) ?
|
||||||
|
(row * this.matrixWidth + col) :
|
||||||
|
(row * this.matrixWidth + (this.matrixWidth - 1 - col));
|
||||||
|
const pixelStart = hardwareIndex * 6;
|
||||||
|
|
||||||
|
if (pixelStart + 5 < pixelData.length) {
|
||||||
|
const hexColor = pixelData.substring(pixelStart, pixelStart + 6);
|
||||||
|
this.renderPixel(col, row, hexColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPixel(col, row, hexColor) {
|
||||||
|
const x = col * this.pixelSize;
|
||||||
|
const y = row * this.pixelSize;
|
||||||
|
|
||||||
|
// Convert hex to RGB
|
||||||
|
const r = parseInt(hexColor.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hexColor.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hexColor.substring(4, 6), 16);
|
||||||
|
|
||||||
|
// Skip rendering if pixel is black (optimization)
|
||||||
|
if (r === 0 && g === 0 && b === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw pixel with a subtle border for better visibility
|
||||||
|
this.ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||||
|
this.ctx.fillRect(x + 1, y + 1, this.pixelSize - 2, this.pixelSize - 2);
|
||||||
|
|
||||||
|
// Add a subtle glow effect for brighter pixels
|
||||||
|
if (r > 128 || g > 128 || b > 128) {
|
||||||
|
this.ctx.shadowColor = `rgb(${r}, ${g}, ${b})`;
|
||||||
|
this.ctx.shadowBlur = 2;
|
||||||
|
this.ctx.fillRect(x + 1, y + 1, this.pixelSize - 2, this.pixelSize - 2);
|
||||||
|
this.ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to render a test pattern
|
||||||
|
renderTestPattern() {
|
||||||
|
this.frameData = 'RAW:';
|
||||||
|
for (let row = 0; row < this.matrixHeight; row++) {
|
||||||
|
for (let col = 0; col < this.matrixWidth; col++) {
|
||||||
|
// Calculate serpentine index manually
|
||||||
|
const hardwareIndex = (row % 2 === 0) ?
|
||||||
|
(row * this.matrixWidth + col) :
|
||||||
|
(row * this.matrixWidth + (this.matrixWidth - 1 - col));
|
||||||
|
// Create a checkerboard pattern
|
||||||
|
if ((row + col) % 2 === 0) {
|
||||||
|
this.frameData += '00ff00'; // Green
|
||||||
|
} else {
|
||||||
|
this.frameData += '000000'; // Black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to clear the matrix
|
||||||
|
clearMatrix() {
|
||||||
|
if (this.ctx && this.canvas) {
|
||||||
|
this.ctx.fillStyle = 'var(--matrix-bg)';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
}
|
||||||
|
this.frameData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update matrix size display
|
||||||
|
updateMatrixSize(width, height) {
|
||||||
|
const sizeElement = this.findElement('#matrix-size');
|
||||||
|
if (sizeElement) {
|
||||||
|
sizeElement.textContent = `${width}x${height}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = MatrixDisplay;
|
||||||
|
}
|
||||||
178
public/scripts/node-discovery.js
Normal file
178
public/scripts/node-discovery.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
// Node Discovery Component
|
||||||
|
|
||||||
|
class NodeDiscovery extends Component {
|
||||||
|
constructor(container, viewModel, eventBus) {
|
||||||
|
super(container, viewModel, eventBus);
|
||||||
|
this.nodes = [];
|
||||||
|
this.currentTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
super.mount();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupViewModelListeners();
|
||||||
|
this.loadNodes();
|
||||||
|
this.startPeriodicRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Broadcast button
|
||||||
|
const broadcastBtn = this.findElement('#broadcast-btn');
|
||||||
|
if (broadcastBtn) {
|
||||||
|
this.addEventListener(broadcastBtn, 'click', () => {
|
||||||
|
this.selectBroadcastTarget();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupViewModelListeners() {
|
||||||
|
this.subscribeToEvent('nodeDiscovered', (data) => {
|
||||||
|
this.addOrUpdateNode(data.node);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscribeToEvent('nodeLost', (data) => {
|
||||||
|
this.removeNode(data.node.ip);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscribeToEvent('status', (data) => {
|
||||||
|
// Update UI to reflect current server state
|
||||||
|
if (data.data.nodes) {
|
||||||
|
this.nodes = data.data.nodes;
|
||||||
|
this.currentTarget = data.data.currentTarget;
|
||||||
|
this.renderNodeList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNodes() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/nodes');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.nodes = data.nodes || [];
|
||||||
|
this.renderNodeList();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading nodes:', error);
|
||||||
|
this.showError('Failed to load nodes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startPeriodicRefresh() {
|
||||||
|
// Refresh node list every 5 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
this.loadNodes();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
addOrUpdateNode(node) {
|
||||||
|
const existingIndex = this.nodes.findIndex(n => n.ip === node.ip);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Update existing node
|
||||||
|
this.nodes[existingIndex] = { ...node, lastSeen: Date.now() };
|
||||||
|
} else {
|
||||||
|
// Add new node
|
||||||
|
this.nodes.push({ ...node, lastSeen: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderNodeList();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeNode(nodeIp) {
|
||||||
|
this.nodes = this.nodes.filter(node => node.ip !== nodeIp);
|
||||||
|
this.renderNodeList();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNodeList() {
|
||||||
|
const nodeListContainer = this.findElement('#node-list');
|
||||||
|
if (!nodeListContainer) return;
|
||||||
|
|
||||||
|
if (this.nodes.length === 0) {
|
||||||
|
nodeListContainer.innerHTML = '<div class="empty-state">No nodes discovered</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = this.nodes.map(node => `
|
||||||
|
<div class="node-item ${node.status} ${node.ip === this.currentTarget ? 'selected' : ''}" data-ip="${this.escapeHtml(node.ip)}" style="cursor: pointer;">
|
||||||
|
<div class="node-indicator ${node.status}"></div>
|
||||||
|
<div class="node-info">
|
||||||
|
<div class="node-ip">${this.escapeHtml(node.ip === 'broadcast' ? 'Broadcast' : node.ip)}</div>
|
||||||
|
<div class="node-status">${node.status} • Port ${node.port}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
nodeListContainer.innerHTML = html;
|
||||||
|
|
||||||
|
// Add click handlers for node selection
|
||||||
|
this.nodes.forEach(node => {
|
||||||
|
const nodeElement = nodeListContainer.querySelector(`[data-ip="${node.ip}"]`);
|
||||||
|
if (nodeElement) {
|
||||||
|
this.addEventListener(nodeElement, 'click', () => {
|
||||||
|
this.selectNode(node.ip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNode(nodeIp) {
|
||||||
|
this.currentTarget = nodeIp;
|
||||||
|
this.viewModel.publish('selectNode', { nodeIp });
|
||||||
|
|
||||||
|
// Update visual selection
|
||||||
|
const nodeListContainer = this.findElement('#node-list');
|
||||||
|
if (nodeListContainer) {
|
||||||
|
nodeListContainer.querySelectorAll('.node-item').forEach(item => {
|
||||||
|
item.classList.remove('selected');
|
||||||
|
});
|
||||||
|
const selectedNode = nodeListContainer.querySelector(`[data-ip="${nodeIp}"]`);
|
||||||
|
if (selectedNode) {
|
||||||
|
selectedNode.classList.add('selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectBroadcast() {
|
||||||
|
this.currentTarget = 'broadcast';
|
||||||
|
this.viewModel.publish('selectBroadcast', {});
|
||||||
|
|
||||||
|
// Update visual selection
|
||||||
|
const nodeListContainer = this.findElement('#node-list');
|
||||||
|
if (nodeListContainer) {
|
||||||
|
nodeListContainer.querySelectorAll('.node-item').forEach(item => {
|
||||||
|
item.classList.remove('selected');
|
||||||
|
});
|
||||||
|
const broadcastNode = nodeListContainer.querySelector(`[data-ip="broadcast"]`);
|
||||||
|
if (broadcastNode) {
|
||||||
|
broadcastNode.classList.add('selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const nodeListContainer = this.findElement('#node-list');
|
||||||
|
if (nodeListContainer) {
|
||||||
|
nodeListContainer.innerHTML = `<div class="error">${this.escapeHtml(message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public method to select broadcast (called from outside)
|
||||||
|
selectBroadcastTarget() {
|
||||||
|
this.selectBroadcast();
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = NodeDiscovery;
|
||||||
|
}
|
||||||
372
public/scripts/preset-controls.js
Normal file
372
public/scripts/preset-controls.js
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
// Preset Controls Component
|
||||||
|
|
||||||
|
class PresetControls extends Component {
|
||||||
|
constructor(container, viewModel, eventBus) {
|
||||||
|
super(container, viewModel, eventBus);
|
||||||
|
this.presets = {};
|
||||||
|
this.currentPreset = null;
|
||||||
|
this.presetControls = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
super.mount();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupViewModelListeners();
|
||||||
|
this.loadPresets();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Preset selection
|
||||||
|
const presetSelect = this.findElement('#preset-select');
|
||||||
|
if (presetSelect) {
|
||||||
|
this.addEventListener(presetSelect, 'change', (e) => {
|
||||||
|
this.selectPreset(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply matrix config button
|
||||||
|
const applyMatrixBtn = this.findElement('#apply-matrix-btn');
|
||||||
|
if (applyMatrixBtn) {
|
||||||
|
this.addEventListener(applyMatrixBtn, 'click', () => {
|
||||||
|
this.applyMatrixConfig();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start/Stop buttons
|
||||||
|
const startBtn = this.findElement('#start-btn');
|
||||||
|
if (startBtn) {
|
||||||
|
this.addEventListener(startBtn, 'click', () => {
|
||||||
|
this.startStreaming();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopBtn = this.findElement('#stop-btn');
|
||||||
|
if (stopBtn) {
|
||||||
|
this.addEventListener(stopBtn, 'click', () => {
|
||||||
|
this.stopStreaming();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test and clear buttons
|
||||||
|
const sendTestBtn = this.findElement('#send-test-btn');
|
||||||
|
if (sendTestBtn) {
|
||||||
|
this.addEventListener(sendTestBtn, 'click', () => {
|
||||||
|
this.sendTestFrame();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMatrixBtn = this.findElement('#clear-matrix-btn');
|
||||||
|
if (clearMatrixBtn) {
|
||||||
|
this.addEventListener(clearMatrixBtn, 'click', () => {
|
||||||
|
this.clearMatrix();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupViewModelListeners() {
|
||||||
|
this.subscribeToEvent('streamingStarted', (data) => {
|
||||||
|
this.updateStreamingState(true, data.preset);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscribeToEvent('streamingStopped', () => {
|
||||||
|
this.updateStreamingState(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscribeToEvent('presetParameterUpdated', (data) => {
|
||||||
|
this.updatePresetParameter(data.parameter, data.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
this.subscribeToEvent('status', (data) => {
|
||||||
|
// Update UI to reflect current server state
|
||||||
|
const isStreaming = data.data.streaming;
|
||||||
|
const currentPreset = data.data.currentPreset;
|
||||||
|
const presetParameters = data.data.presetParameters;
|
||||||
|
|
||||||
|
this.updateStreamingState(isStreaming, currentPreset ? { name: currentPreset } : null);
|
||||||
|
|
||||||
|
if (currentPreset) {
|
||||||
|
this.selectPreset(currentPreset.toLowerCase().replace('-preset', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (presetParameters && this.currentPreset) {
|
||||||
|
// Update parameter controls with current values
|
||||||
|
Object.entries(presetParameters).forEach(([param, value]) => {
|
||||||
|
const control = this.presetControls.get(param);
|
||||||
|
if (control) {
|
||||||
|
if (control.type === 'range') {
|
||||||
|
control.value = value;
|
||||||
|
const valueDisplay = control.parentElement.querySelector('.preset-value');
|
||||||
|
if (valueDisplay) {
|
||||||
|
valueDisplay.textContent = parseFloat(value).toFixed(2);
|
||||||
|
}
|
||||||
|
} else if (control.type === 'color') {
|
||||||
|
control.value = this.hexToColorValue(value);
|
||||||
|
} else {
|
||||||
|
control.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPresets() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/presets');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.presets = data.presets;
|
||||||
|
this.populatePresetSelect();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading presets:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
populatePresetSelect() {
|
||||||
|
const presetSelect = this.findElement('#preset-select');
|
||||||
|
if (!presetSelect) return;
|
||||||
|
|
||||||
|
// Clear existing options (except the first one)
|
||||||
|
while (presetSelect.children.length > 1) {
|
||||||
|
presetSelect.removeChild(presetSelect.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add preset options
|
||||||
|
Object.entries(this.presets).forEach(([name, metadata]) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = name;
|
||||||
|
option.textContent = metadata.name;
|
||||||
|
presetSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPreset(presetName) {
|
||||||
|
if (!presetName || !this.presets[presetName]) {
|
||||||
|
this.currentPreset = null;
|
||||||
|
this.clearPresetControls();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentPreset = this.presets[presetName];
|
||||||
|
this.createPresetControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
createPresetControls() {
|
||||||
|
const controlsContainer = this.findElement('#preset-controls');
|
||||||
|
if (!controlsContainer) return;
|
||||||
|
|
||||||
|
// Clear existing controls
|
||||||
|
controlsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (!this.currentPreset || !this.currentPreset.parameters) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create controls for each parameter
|
||||||
|
Object.entries(this.currentPreset.parameters).forEach(([paramName, paramConfig]) => {
|
||||||
|
const controlDiv = document.createElement('div');
|
||||||
|
controlDiv.className = 'preset-control';
|
||||||
|
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'preset-label';
|
||||||
|
label.textContent = this.formatParameterName(paramName);
|
||||||
|
controlDiv.appendChild(label);
|
||||||
|
|
||||||
|
const input = this.createParameterInput(paramName, paramConfig);
|
||||||
|
controlDiv.appendChild(input);
|
||||||
|
|
||||||
|
controlsContainer.appendChild(controlDiv);
|
||||||
|
this.presetControls.set(paramName, input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createParameterInput(paramName, paramConfig) {
|
||||||
|
const { type, min, max, step, default: defaultValue } = paramConfig;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'range':
|
||||||
|
const sliderInput = document.createElement('input');
|
||||||
|
sliderInput.type = 'range';
|
||||||
|
sliderInput.className = 'preset-slider';
|
||||||
|
sliderInput.min = min;
|
||||||
|
sliderInput.max = max;
|
||||||
|
sliderInput.step = step || 0.1;
|
||||||
|
sliderInput.value = defaultValue;
|
||||||
|
|
||||||
|
// Add value display
|
||||||
|
const valueDisplay = document.createElement('span');
|
||||||
|
valueDisplay.className = 'preset-value';
|
||||||
|
valueDisplay.textContent = defaultValue;
|
||||||
|
|
||||||
|
sliderInput.addEventListener('input', (e) => {
|
||||||
|
valueDisplay.textContent = parseFloat(e.target.value).toFixed(2);
|
||||||
|
this.updatePresetParameter(paramName, parseFloat(e.target.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.display = 'flex';
|
||||||
|
container.style.alignItems = 'center';
|
||||||
|
container.style.gap = '0.5rem';
|
||||||
|
container.appendChild(sliderInput);
|
||||||
|
container.appendChild(valueDisplay);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
|
||||||
|
case 'color':
|
||||||
|
const colorInput = document.createElement('input');
|
||||||
|
colorInput.type = 'color';
|
||||||
|
colorInput.className = 'preset-input';
|
||||||
|
colorInput.value = this.hexToColorValue(defaultValue);
|
||||||
|
|
||||||
|
colorInput.addEventListener('input', (e) => {
|
||||||
|
const hexValue = this.colorValueToHex(e.target.value);
|
||||||
|
this.updatePresetParameter(paramName, hexValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return colorInput;
|
||||||
|
|
||||||
|
default:
|
||||||
|
const textInput = document.createElement('input');
|
||||||
|
textInput.type = 'text';
|
||||||
|
textInput.className = 'preset-input';
|
||||||
|
textInput.value = defaultValue;
|
||||||
|
|
||||||
|
textInput.addEventListener('input', (e) => {
|
||||||
|
this.updatePresetParameter(paramName, e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return textInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePresetParameter(parameter, value) {
|
||||||
|
// Send parameter update to server
|
||||||
|
this.viewModel.publish('updatePresetParameter', {
|
||||||
|
parameter,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPresetControls() {
|
||||||
|
const controlsContainer = this.findElement('#preset-controls');
|
||||||
|
if (controlsContainer) {
|
||||||
|
controlsContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
this.presetControls.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatParameterName(name) {
|
||||||
|
return name
|
||||||
|
.split(/(?=[A-Z])/)
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
hexToColorValue(hex) {
|
||||||
|
// Convert hex (rrggbb) to color value (#rrggbb)
|
||||||
|
if (hex.startsWith('#')) return hex;
|
||||||
|
return `#${hex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
colorValueToHex(colorValue) {
|
||||||
|
// Convert color value (#rrggbb) to hex (rrggbb)
|
||||||
|
return colorValue.replace('#', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
startStreaming() {
|
||||||
|
const presetSelect = this.findElement('#preset-select');
|
||||||
|
if (!presetSelect || !presetSelect.value) {
|
||||||
|
alert('Please select a preset first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = parseInt(this.findElement('#matrix-width')?.value) || 16;
|
||||||
|
const height = parseInt(this.findElement('#matrix-height')?.value) || 16;
|
||||||
|
|
||||||
|
this.viewModel.publish('startPreset', {
|
||||||
|
presetName: presetSelect.value,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stopStreaming() {
|
||||||
|
this.viewModel.publish('stopStreaming', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTestFrame() {
|
||||||
|
// Create a test frame with a simple pattern in serpentine order
|
||||||
|
const width = parseInt(this.findElement('#matrix-width')?.value) || 16;
|
||||||
|
const height = parseInt(this.findElement('#matrix-height')?.value) || 16;
|
||||||
|
|
||||||
|
let frameData = 'RAW:';
|
||||||
|
for (let row = 0; row < height; row++) {
|
||||||
|
for (let col = 0; col < width; col++) {
|
||||||
|
// Calculate serpentine index manually
|
||||||
|
const hardwareIndex = (row % 2 === 0) ?
|
||||||
|
(row * width + col) :
|
||||||
|
(row * width + (width - 1 - col));
|
||||||
|
// Create a checkerboard pattern
|
||||||
|
if ((row + col) % 2 === 0) {
|
||||||
|
frameData += '00ff00'; // Green
|
||||||
|
} else {
|
||||||
|
frameData += '000000'; // Black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewModel.publish('broadcastToAll', {
|
||||||
|
message: frameData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMatrix() {
|
||||||
|
// Send a frame with all black pixels in serpentine order
|
||||||
|
const width = parseInt(this.findElement('#matrix-width')?.value) || 16;
|
||||||
|
const height = parseInt(this.findElement('#matrix-height')?.value) || 16;
|
||||||
|
|
||||||
|
let frameData = 'RAW:';
|
||||||
|
for (let row = 0; row < height; row++) {
|
||||||
|
for (let col = 0; col < width; col++) {
|
||||||
|
// Calculate serpentine index manually
|
||||||
|
const hardwareIndex = (row % 2 === 0) ?
|
||||||
|
(row * width + col) :
|
||||||
|
(row * width + (width - 1 - col));
|
||||||
|
frameData += '000000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewModel.publish('broadcastToAll', {
|
||||||
|
message: frameData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMatrixConfig() {
|
||||||
|
const width = parseInt(this.findElement('#matrix-width')?.value);
|
||||||
|
const height = parseInt(this.findElement('#matrix-height')?.value);
|
||||||
|
|
||||||
|
if (width && height) {
|
||||||
|
this.viewModel.publish('setMatrixSize', { width, height });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStreamingState(isStreaming, preset) {
|
||||||
|
const startBtn = this.findElement('#start-btn');
|
||||||
|
const stopBtn = this.findElement('#stop-btn');
|
||||||
|
|
||||||
|
if (isStreaming) {
|
||||||
|
startBtn.disabled = true;
|
||||||
|
stopBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
startBtn.disabled = false;
|
||||||
|
stopBtn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = PresetControls;
|
||||||
|
}
|
||||||
120
public/scripts/theme-manager.js
Normal file
120
public/scripts/theme-manager.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Theme Manager - Handles theme switching and persistence
|
||||||
|
|
||||||
|
class ThemeManager {
|
||||||
|
constructor() {
|
||||||
|
this.currentTheme = this.getStoredTheme() || 'dark';
|
||||||
|
this.themeToggle = document.getElementById('theme-toggle');
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Apply stored theme on page load
|
||||||
|
this.applyTheme(this.currentTheme);
|
||||||
|
|
||||||
|
// Set up event listener for theme toggle
|
||||||
|
if (this.themeToggle) {
|
||||||
|
this.themeToggle.addEventListener('click', () => this.toggleTheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
if (window.matchMedia) {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
mediaQuery.addListener((e) => {
|
||||||
|
if (this.getStoredTheme() === 'system') {
|
||||||
|
this.applyTheme(e.matches ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStoredTheme() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('spore-ledlab-theme');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not access localStorage for theme preference');
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStoredTheme(theme) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('spore-ledlab-theme', theme);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not save theme preference to localStorage');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(theme) {
|
||||||
|
// Update data attribute on html element
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
|
||||||
|
// Update theme toggle icon
|
||||||
|
this.updateThemeIcon(theme);
|
||||||
|
|
||||||
|
// Store the theme preference
|
||||||
|
this.setStoredTheme(theme);
|
||||||
|
|
||||||
|
this.currentTheme = theme;
|
||||||
|
|
||||||
|
// Dispatch custom event for other components
|
||||||
|
window.dispatchEvent(new CustomEvent('themeChanged', {
|
||||||
|
detail: { theme: theme }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateThemeIcon(theme) {
|
||||||
|
if (!this.themeToggle) return;
|
||||||
|
|
||||||
|
const svg = this.themeToggle.querySelector('svg');
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
// Update the SVG content based on theme
|
||||||
|
if (theme === 'light') {
|
||||||
|
// Sun icon for light theme
|
||||||
|
svg.innerHTML = `
|
||||||
|
<circle cx="12" cy="12" r="5"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Moon icon for dark theme
|
||||||
|
svg.innerHTML = `
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTheme() {
|
||||||
|
const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
this.applyTheme(newTheme);
|
||||||
|
|
||||||
|
// Add a subtle animation to the toggle button
|
||||||
|
if (this.themeToggle) {
|
||||||
|
this.themeToggle.style.transform = 'scale(0.9)';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.themeToggle.style.transform = 'scale(1)';
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to get current theme (useful for other components)
|
||||||
|
getCurrentTheme() {
|
||||||
|
return this.currentTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to set theme programmatically
|
||||||
|
setTheme(theme) {
|
||||||
|
if (['dark', 'light'].includes(theme)) {
|
||||||
|
this.applyTheme(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize theme manager when DOM is loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
window.themeManager = new ThemeManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = ThemeManager;
|
||||||
|
}
|
||||||
562
public/styles/main.css
Normal file
562
public/styles/main.css
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Dark theme colors */
|
||||||
|
--bg-primary: #0f0f0f;
|
||||||
|
--bg-secondary: #1a1a1a;
|
||||||
|
--bg-tertiary: #2d2d2d;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #b3b3b3;
|
||||||
|
--text-tertiary: #888888;
|
||||||
|
--accent-primary: #4ade80;
|
||||||
|
--accent-secondary: #22d3ee;
|
||||||
|
--accent-warning: #fbbf24;
|
||||||
|
--accent-error: #f87171;
|
||||||
|
--border-primary: #333333;
|
||||||
|
--border-secondary: #444444;
|
||||||
|
--shadow-primary: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
--backdrop-blur: blur(12px);
|
||||||
|
|
||||||
|
/* LEDLab specific colors */
|
||||||
|
--matrix-bg: #000000;
|
||||||
|
--pixel-on: #4ade80;
|
||||||
|
--pixel-off: #1a1a1a;
|
||||||
|
--pixel-dim: #22c55e;
|
||||||
|
--control-bg: #1e1e1e;
|
||||||
|
--preset-active: #4ade80;
|
||||||
|
--node-connected: #22c55e;
|
||||||
|
--node-disconnected: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f8fafc;
|
||||||
|
--bg-tertiary: #e2e8f0;
|
||||||
|
--text-primary: #1e293b;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--text-tertiary: #94a3b8;
|
||||||
|
--accent-primary: #16a34a;
|
||||||
|
--accent-secondary: #0891b2;
|
||||||
|
--accent-warning: #d97706;
|
||||||
|
--accent-error: #dc2626;
|
||||||
|
--border-primary: #e2e8f0;
|
||||||
|
--border-secondary: #cbd5e1;
|
||||||
|
--shadow-primary: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
--backdrop-blur: blur(12px);
|
||||||
|
|
||||||
|
/* Light theme LEDLab colors */
|
||||||
|
--matrix-bg: #f8fafc;
|
||||||
|
--pixel-on: #16a34a;
|
||||||
|
--pixel-off: #f1f5f9;
|
||||||
|
--pixel-dim: #22c55e;
|
||||||
|
--control-bg: #f8fafc;
|
||||||
|
--preset-active: #16a34a;
|
||||||
|
--node-connected: #16a34a;
|
||||||
|
--node-disconnected: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
height: 100vh;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 2rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LEDLab specific styles */
|
||||||
|
.ledlab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledlab-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content layout */
|
||||||
|
.ledlab-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Matrix display section */
|
||||||
|
.matrix-section {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--matrix-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-secondary);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-canvas {
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: -moz-crisp-edges;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control panel section */
|
||||||
|
.control-section {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
background: var(--control-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node list */
|
||||||
|
.node-controls {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-item.connected {
|
||||||
|
border-left: 3px solid var(--node-connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-item.disconnected {
|
||||||
|
border-left: 3px solid var(--node-disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-item.selected {
|
||||||
|
background: rgba(74, 222, 128, 0.1);
|
||||||
|
border-left: 3px solid var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--node-disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-indicator.connected {
|
||||||
|
background: var(--node-connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-info {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-ip {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preset controls */
|
||||||
|
.preset-select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-slider::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--accent-secondary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Matrix configuration */
|
||||||
|
.matrix-config {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-input label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-input input {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-input input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.status-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connected {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: var(--node-connected);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnected {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--node-disconnected);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-streaming {
|
||||||
|
background: rgba(74, 222, 128, 0.1);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border: 1px solid rgba(74, 222, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading and error states */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--accent-error);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.control-section::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-section::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-section::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-section::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ledlab-main {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-section {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-section {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation keyframes */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
483
server/index.js
Normal file
483
server/index.js
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
// LEDLab Server - Main server file
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Import services
|
||||||
|
const UdpDiscovery = require('./udp-discovery');
|
||||||
|
const PresetRegistry = require('../presets/preset-registry');
|
||||||
|
|
||||||
|
class LEDLabServer {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.port = options.port || 8080;
|
||||||
|
this.udpPort = options.udpPort || 4210;
|
||||||
|
this.matrixWidth = options.matrixWidth || 16;
|
||||||
|
this.matrixHeight = options.matrixHeight || 16;
|
||||||
|
|
||||||
|
this.app = express();
|
||||||
|
this.server = http.createServer(this.app);
|
||||||
|
this.wss = new WebSocket.Server({ server: this.server });
|
||||||
|
|
||||||
|
this.udpDiscovery = new UdpDiscovery(this.udpPort);
|
||||||
|
this.presetRegistry = new PresetRegistry();
|
||||||
|
|
||||||
|
this.currentPreset = null;
|
||||||
|
this.currentPresetName = null;
|
||||||
|
this.streamingInterval = null;
|
||||||
|
this.connectedClients = new Set();
|
||||||
|
|
||||||
|
// Per-node configurations and current target
|
||||||
|
this.nodeConfigurations = new Map(); // ip -> {presetName, parameters, matrixSize}
|
||||||
|
this.currentTarget = null; // 'broadcast' or specific IP
|
||||||
|
|
||||||
|
this.setupExpress();
|
||||||
|
this.setupWebSocket();
|
||||||
|
this.setupUdpDiscovery();
|
||||||
|
this.setupPresetManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupExpress() {
|
||||||
|
// Serve static files
|
||||||
|
this.app.use(express.static(path.join(__dirname, '../public')));
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
this.app.get('/api/nodes', (req, res) => {
|
||||||
|
const nodes = this.udpDiscovery.getNodes();
|
||||||
|
res.json({ nodes });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.get('/api/presets', (req, res) => {
|
||||||
|
const presets = this.presetRegistry.getAllPresetMetadata();
|
||||||
|
res.json({ presets });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.get('/api/status', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
streaming: this.currentPreset !== null,
|
||||||
|
currentPreset: this.currentPreset ? this.currentPreset.getMetadata().name : null,
|
||||||
|
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
|
||||||
|
nodeCount: this.udpDiscovery.getNodeCount(),
|
||||||
|
currentTarget: this.currentTarget,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Matrix configuration endpoint
|
||||||
|
this.app.post('/api/matrix/config', express.json(), (req, res) => {
|
||||||
|
const { width, height } = req.body;
|
||||||
|
|
||||||
|
if (width && height && width > 0 && height > 0) {
|
||||||
|
this.matrixWidth = width;
|
||||||
|
this.matrixHeight = height;
|
||||||
|
|
||||||
|
// Restart current preset with new dimensions if one is active
|
||||||
|
if (this.currentPreset) {
|
||||||
|
this.stopStreaming();
|
||||||
|
this.startPreset(this.currentPreset.constructor.name.toLowerCase().replace('-preset', ''), width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastToClients({
|
||||||
|
type: 'matrixConfig',
|
||||||
|
config: { width, height }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save updated configuration for current target
|
||||||
|
if (this.currentTarget && this.currentTarget !== 'broadcast') {
|
||||||
|
this.saveCurrentConfiguration(this.currentTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, config: { width, height } });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'Invalid matrix dimensions' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupWebSocket() {
|
||||||
|
this.wss.on('connection', (ws) => {
|
||||||
|
console.log('WebSocket client connected');
|
||||||
|
this.connectedClients.add(ws);
|
||||||
|
|
||||||
|
ws.on('message', (message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.toString());
|
||||||
|
this.handleWebSocketMessage(ws, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log('WebSocket client disconnected');
|
||||||
|
this.connectedClients.delete(ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send current status to new client
|
||||||
|
const currentState = {
|
||||||
|
streaming: this.currentPreset !== null,
|
||||||
|
currentPreset: this.currentPreset ? this.currentPreset.getMetadata().name : null,
|
||||||
|
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
|
||||||
|
nodes: this.udpDiscovery.getNodes(),
|
||||||
|
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
|
||||||
|
currentTarget: this.currentTarget,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sendToClient(ws, {
|
||||||
|
type: 'status',
|
||||||
|
data: currentState
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWebSocketMessage(ws, data) {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'startPreset':
|
||||||
|
this.startPreset(data.presetName, data.width, data.height);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stopStreaming':
|
||||||
|
this.stopStreaming();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'updatePresetParameter':
|
||||||
|
this.updatePresetParameter(data.parameter, data.value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'setMatrixSize':
|
||||||
|
this.setMatrixSize(data.width, data.height);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'selectNode':
|
||||||
|
this.selectNode(data.nodeIp);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'selectBroadcast':
|
||||||
|
this.selectBroadcast();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'sendToNode':
|
||||||
|
this.sendToSpecificNode(data.nodeIp, data.message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'broadcastToAll':
|
||||||
|
this.broadcastToAllNodes(data.message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('Unknown WebSocket message type:', data.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupUdpDiscovery() {
|
||||||
|
this.udpDiscovery.on('nodeDiscovered', (node) => {
|
||||||
|
console.log('Node discovered:', node.ip);
|
||||||
|
|
||||||
|
this.broadcastToClients({
|
||||||
|
type: 'nodeDiscovered',
|
||||||
|
node
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.udpDiscovery.on('nodeLost', (node) => {
|
||||||
|
console.log('Node lost:', node.ip);
|
||||||
|
|
||||||
|
this.broadcastToClients({
|
||||||
|
type: 'nodeLost',
|
||||||
|
node
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.udpDiscovery.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupPresetManager() {
|
||||||
|
// Start with no active preset
|
||||||
|
this.currentPreset = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startPreset(presetName, width = this.matrixWidth, height = this.matrixHeight) {
|
||||||
|
try {
|
||||||
|
// Stop current streaming if active
|
||||||
|
if (this.currentPreset) {
|
||||||
|
this.stopStreaming();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new preset instance
|
||||||
|
this.currentPreset = this.presetRegistry.createPreset(presetName, width, height);
|
||||||
|
this.currentPresetName = presetName; // Store the registry key
|
||||||
|
this.currentPreset.start();
|
||||||
|
|
||||||
|
console.log(`Started preset: ${presetName} (${width}x${height})`);
|
||||||
|
|
||||||
|
// Start streaming interval
|
||||||
|
this.streamingInterval = setInterval(() => {
|
||||||
|
this.streamFrame();
|
||||||
|
}, 50); // 20 FPS
|
||||||
|
|
||||||
|
// Notify clients
|
||||||
|
this.broadcastToClients({
|
||||||
|
type: 'streamingStarted',
|
||||||
|
preset: this.currentPreset.getMetadata()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also send updated state to keep all clients in sync
|
||||||
|
this.broadcastCurrentState();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error starting preset:', error);
|
||||||
|
this.sendToClient(ws, {
|
||||||
|
type: 'error',
|
||||||
|
message: `Failed to start preset: ${error.message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopStreaming() {
|
||||||
|
if (this.streamingInterval) {
|
||||||
|
clearInterval(this.streamingInterval);
|
||||||
|
this.streamingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentPreset) {
|
||||||
|
this.currentPreset.stop();
|
||||||
|
this.currentPreset = null;
|
||||||
|
this.currentPresetName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Streaming stopped');
|
||||||
|
|
||||||
|
this.broadcastToClients({
|
||||||
|
type: 'streamingStopped'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save current configuration for the current target if it exists
|
||||||
|
if (this.currentTarget && this.currentTarget !== 'broadcast') {
|
||||||
|
this.saveCurrentConfiguration(this.currentTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also send updated state to keep all clients in sync
|
||||||
|
this.broadcastCurrentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePresetParameter(parameter, value) {
|
||||||
|
if (this.currentPreset) {
|
||||||
|
this.currentPreset.setParameter(parameter, value);
|
||||||
|
|
||||||
|
this.broadcastToClients({
|
||||||
|
type: 'presetParameterUpdated',
|
||||||
|
parameter,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save updated configuration for current target
|
||||||
|
if (this.currentTarget && this.currentTarget !== 'broadcast') {
|
||||||
|
this.saveCurrentConfiguration(this.currentTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also send updated state to keep all clients in sync
|
||||||
|
this.broadcastCurrentState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMatrixSize(width, height) {
|
||||||
|
this.matrixWidth = width;
|
||||||
|
this.matrixHeight = height;
|
||||||
|
|
||||||
|
if (this.currentPreset) {
|
||||||
|
this.stopStreaming();
|
||||||
|
this.startPreset(this.currentPreset.constructor.name.toLowerCase().replace('-preset', ''), width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastToClients({
|
||||||
|
type: 'matrixSizeChanged',
|
||||||
|
size: { width, height }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save updated configuration for current target
|
||||||
|
if (this.currentTarget && this.currentTarget !== 'broadcast') {
|
||||||
|
this.saveCurrentConfiguration(this.currentTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
streamFrame() {
|
||||||
|
if (!this.currentPreset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameData = this.currentPreset.generateFrame();
|
||||||
|
if (frameData) {
|
||||||
|
// Send to specific target or broadcast
|
||||||
|
if (this.currentTarget === 'broadcast') {
|
||||||
|
this.udpDiscovery.broadcastToAll(frameData);
|
||||||
|
} else if (this.currentTarget) {
|
||||||
|
this.udpDiscovery.sendToNode(this.currentTarget, frameData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send frame data to WebSocket clients for preview
|
||||||
|
this.broadcastToClients({
|
||||||
|
type: 'frame',
|
||||||
|
data: frameData,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToSpecificNode(nodeIp, message) {
|
||||||
|
return this.udpDiscovery.sendToNode(nodeIp, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToAllNodes(message) {
|
||||||
|
return this.udpDiscovery.broadcastToAll(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastCurrentState() {
|
||||||
|
const currentState = {
|
||||||
|
streaming: this.currentPreset !== null,
|
||||||
|
currentPreset: this.currentPreset ? this.currentPreset.getMetadata().name : null,
|
||||||
|
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
|
||||||
|
nodes: this.udpDiscovery.getNodes(),
|
||||||
|
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
|
||||||
|
currentTarget: this.currentTarget,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.broadcastToClients({
|
||||||
|
type: 'status',
|
||||||
|
data: currentState
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node selection and configuration management
|
||||||
|
selectNode(nodeIp) {
|
||||||
|
this.currentTarget = nodeIp;
|
||||||
|
|
||||||
|
// Load configuration for this node if it exists, otherwise use current settings
|
||||||
|
const nodeConfig = this.nodeConfigurations.get(nodeIp);
|
||||||
|
if (nodeConfig) {
|
||||||
|
this.loadNodeConfiguration(nodeConfig);
|
||||||
|
} else {
|
||||||
|
// Save current configuration for this node
|
||||||
|
this.saveCurrentConfiguration(nodeIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastCurrentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectBroadcast() {
|
||||||
|
this.currentTarget = 'broadcast';
|
||||||
|
this.broadcastCurrentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrentConfiguration(nodeIp) {
|
||||||
|
if (this.currentPreset && this.currentPresetName) {
|
||||||
|
this.nodeConfigurations.set(nodeIp, {
|
||||||
|
presetName: this.currentPresetName, // Use registry key, not display name
|
||||||
|
parameters: this.currentPreset.getParameters(),
|
||||||
|
matrixSize: { width: this.matrixWidth, height: this.matrixHeight }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNodeConfiguration(config) {
|
||||||
|
// Stop current streaming
|
||||||
|
this.stopStreaming();
|
||||||
|
|
||||||
|
// Load the node's configuration
|
||||||
|
this.matrixWidth = config.matrixSize.width;
|
||||||
|
this.matrixHeight = config.matrixSize.height;
|
||||||
|
|
||||||
|
// Start the preset with saved parameters
|
||||||
|
this.startPreset(config.presetName, config.matrixSize.width, config.matrixSize.height);
|
||||||
|
|
||||||
|
// Set the parameters after preset is created
|
||||||
|
if (this.currentPreset) {
|
||||||
|
Object.entries(config.parameters).forEach(([param, value]) => {
|
||||||
|
this.currentPreset.setParameter(param, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToClients(message) {
|
||||||
|
const messageStr = JSON.stringify(message);
|
||||||
|
this.connectedClients.forEach(ws => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(messageStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToClient(ws, message) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer() {
|
||||||
|
this.server.listen(this.port, () => {
|
||||||
|
console.log(`LEDLab server running on port ${this.port}`);
|
||||||
|
console.log(`UDP discovery on port ${this.udpPort}`);
|
||||||
|
console.log(`Matrix size: ${this.matrixWidth}x${this.matrixHeight}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stopServer() {
|
||||||
|
console.log('Stopping LEDLab server...');
|
||||||
|
|
||||||
|
// Stop streaming first
|
||||||
|
this.stopStreaming();
|
||||||
|
|
||||||
|
// Stop UDP discovery
|
||||||
|
this.udpDiscovery.stop();
|
||||||
|
|
||||||
|
// Close all WebSocket connections immediately
|
||||||
|
this.wss.close();
|
||||||
|
|
||||||
|
// Close the server
|
||||||
|
this.server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error closing server:', err);
|
||||||
|
}
|
||||||
|
console.log('LEDLab server stopped');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force exit after 2 seconds if server doesn't close cleanly
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Forcing server shutdown...');
|
||||||
|
process.exit(1);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global flag to ensure shutdown handlers are only registered once
|
||||||
|
let shutdownHandlersRegistered = false;
|
||||||
|
|
||||||
|
// Start server if this file is run directly
|
||||||
|
if (require.main === module) {
|
||||||
|
const server = new LEDLabServer({
|
||||||
|
port: process.env.PORT || 8080,
|
||||||
|
udpPort: process.env.UDP_PORT || 4210,
|
||||||
|
matrixWidth: parseInt(process.env.MATRIX_WIDTH) || 16,
|
||||||
|
matrixHeight: parseInt(process.env.MATRIX_HEIGHT) || 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown (only register once)
|
||||||
|
if (!shutdownHandlersRegistered) {
|
||||||
|
shutdownHandlersRegistered = true;
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\nShutting down LEDLab server...');
|
||||||
|
server.stopServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('\nShutting down LEDLab server...');
|
||||||
|
server.stopServer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
server.startServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LEDLabServer;
|
||||||
213
server/udp-discovery.js
Normal file
213
server/udp-discovery.js
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
// UDP Discovery service for SPORE nodes
|
||||||
|
|
||||||
|
const dgram = require('dgram');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
class UdpDiscovery extends EventEmitter {
|
||||||
|
constructor(port = 4210) {
|
||||||
|
super();
|
||||||
|
this.port = port;
|
||||||
|
this.socket = null;
|
||||||
|
this.nodes = new Map(); // ip -> { lastSeen, status }
|
||||||
|
this.discoveryInterval = null;
|
||||||
|
this.isRunning = false;
|
||||||
|
|
||||||
|
// Get local network interfaces to filter out local server
|
||||||
|
this.localInterfaces = this.getLocalInterfaces();
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalInterfaces() {
|
||||||
|
const interfaces = os.networkInterfaces();
|
||||||
|
const localIPs = new Set();
|
||||||
|
|
||||||
|
Object.values(interfaces).forEach(iface => {
|
||||||
|
iface.forEach(addr => {
|
||||||
|
if (addr.family === 'IPv4' && !addr.internal) {
|
||||||
|
localIPs.add(addr.address);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return localIPs;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket = dgram.createSocket('udp4');
|
||||||
|
this.isRunning = true;
|
||||||
|
|
||||||
|
this.socket.on('message', (msg, rinfo) => {
|
||||||
|
this.handleMessage(msg, rinfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('error', (err) => {
|
||||||
|
console.error('UDP Discovery socket error:', err);
|
||||||
|
this.emit('error', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.bind(this.port, () => {
|
||||||
|
console.log(`UDP Discovery listening on port ${this.port}`);
|
||||||
|
// Enable broadcast after binding
|
||||||
|
this.socket.setBroadcast(true);
|
||||||
|
this.emit('started');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start periodic discovery broadcast
|
||||||
|
this.startDiscoveryBroadcast();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = false;
|
||||||
|
|
||||||
|
if (this.discoveryInterval) {
|
||||||
|
clearInterval(this.discoveryInterval);
|
||||||
|
this.discoveryInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.close();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nodes.clear();
|
||||||
|
console.log('UDP Discovery stopped');
|
||||||
|
this.emit('stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(msg, rinfo) {
|
||||||
|
const message = msg.toString('utf8');
|
||||||
|
const nodeIp = rinfo.address;
|
||||||
|
|
||||||
|
// Skip local server IPs
|
||||||
|
if (this.localInterfaces.has(nodeIp)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update node last seen time
|
||||||
|
this.nodes.set(nodeIp, {
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
status: 'connected',
|
||||||
|
address: nodeIp,
|
||||||
|
port: rinfo.port
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit node discovered/updated event
|
||||||
|
this.emit('nodeDiscovered', {
|
||||||
|
ip: nodeIp,
|
||||||
|
port: rinfo.port,
|
||||||
|
status: 'connected'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up stale nodes periodically
|
||||||
|
this.cleanupStaleNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
startDiscoveryBroadcast() {
|
||||||
|
// Broadcast discovery message every 5 seconds
|
||||||
|
this.discoveryInterval = setInterval(() => {
|
||||||
|
this.broadcastDiscovery();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Send initial broadcast
|
||||||
|
this.broadcastDiscovery();
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastDiscovery() {
|
||||||
|
if (!this.socket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const discoveryMessage = 'SPORE_DISCOVERY';
|
||||||
|
const message = Buffer.from(discoveryMessage, 'utf8');
|
||||||
|
|
||||||
|
// Broadcast to all nodes on the network (broadcast already enabled in bind callback)
|
||||||
|
|
||||||
|
this.socket.send(message, 0, message.length, this.port, '255.255.255.255', (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error broadcasting discovery message:', err);
|
||||||
|
} else {
|
||||||
|
console.log('Discovery message broadcasted');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupStaleNodes() {
|
||||||
|
const now = Date.now();
|
||||||
|
const staleThreshold = 10000; // 10 seconds
|
||||||
|
|
||||||
|
for (const [ip, node] of this.nodes.entries()) {
|
||||||
|
if (now - node.lastSeen > staleThreshold) {
|
||||||
|
this.nodes.delete(ip);
|
||||||
|
this.emit('nodeLost', { ip, status: 'disconnected' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodes() {
|
||||||
|
const nodes = Array.from(this.nodes.entries()).map(([ip, node]) => ({
|
||||||
|
ip,
|
||||||
|
...node
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add broadcast option
|
||||||
|
nodes.unshift({
|
||||||
|
ip: 'broadcast',
|
||||||
|
status: 'broadcast',
|
||||||
|
address: '255.255.255.255',
|
||||||
|
port: this.port,
|
||||||
|
isBroadcast: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeCount() {
|
||||||
|
return this.nodes.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToNode(nodeIp, message) {
|
||||||
|
if (!this.socket) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(message, 'utf8');
|
||||||
|
this.socket.send(buffer, 0, buffer.length, this.port, nodeIp, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`Error sending to node ${nodeIp}:`, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToAll(message) {
|
||||||
|
if (!this.socket) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(message, 'utf8');
|
||||||
|
this.socket.setBroadcast(true);
|
||||||
|
|
||||||
|
this.socket.send(buffer, 0, buffer.length, this.port, '255.255.255.255', (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error broadcasting message:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UdpDiscovery;
|
||||||
Reference in New Issue
Block a user