feat: ledlab

This commit is contained in:
2025-10-11 17:46:32 +02:00
commit 30814807aa
30 changed files with 5690 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

23
README.md Normal file
View 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
View 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
View 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"
}
}

View 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
View 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;

View 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;

View 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;

View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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
View 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;

View 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;

View 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;

View 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
View 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>

View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
}
// 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();

View 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;
}

View 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;
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = NodeDiscovery;
}

View 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;
}

View 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
View 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
View 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
View 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;