diff --git a/bun.lock b/bun.lock index 56a202e..611644d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@electron/remote": "^2.1.3", "electron-serve": "^3.0.0", "electron-updater": "^6.6.2", + "webfft": "^1.0.3", }, "devDependencies": { "@radix-ui/react-slot": "^1.2.3", @@ -28,11 +29,10 @@ "electronmon": "^2.0.3", "lucide-react": "^0.546.0", "maplibre-gl": "^5.9.0", - "next": "^15.5.5", + "next": "15.5.7", "next-themes": "^0.4.6", "postcss": "^8.5.6", "react": "^19.2.0", - "react-chartjs-2": "^5.3.0", "react-dom": "^19.2.0", "react-map-gl": "^8.1.0", "tailwind-merge": "^3.3.1", @@ -166,8 +166,6 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], - "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], @@ -190,23 +188,23 @@ "@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.0.3", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@mapbox/vector-tile": "^2.0.4", "@types/geojson-vt": "3.2.5", "@types/supercluster": "^7.1.3", "geojson-vt": "^4.0.2", "pbf": "^4.0.1", "supercluster": "^8.0.1" } }, "sha512-YsW99BwnT+ukJRkseBcLuZHfITB4puJoxnqPVjo72rhW/TaawVYsgQHcqWLzTxqknttYoDpgyERzWSa/XrETdA=="], - "@next/env": ["@next/env@15.5.6", "", {}, "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q=="], + "@next/env": ["@next/env@15.5.7", "", {}, "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw=="], "@npmcli/fs": ["@npmcli/fs@2.1.2", "", { "dependencies": { "@gar/promisify": "^1.1.3", "semver": "^7.3.5" } }, "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ=="], @@ -456,8 +454,6 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], - "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], @@ -912,7 +908,7 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "next": ["next@15.5.6", "", { "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.6", "@next/swc-darwin-x64": "15.5.6", "@next/swc-linux-arm64-gnu": "15.5.6", "@next/swc-linux-arm64-musl": "15.5.6", "@next/swc-linux-x64-gnu": "15.5.6", "@next/swc-linux-x64-musl": "15.5.6", "@next/swc-win32-arm64-msvc": "15.5.6", "@next/swc-win32-x64-msvc": "15.5.6", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ=="], + "next": ["next@15.5.7", "", { "dependencies": { "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], @@ -1012,8 +1008,6 @@ "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], - "react-chartjs-2": ["react-chartjs-2@5.3.0", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw=="], - "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], "react-map-gl": ["react-map-gl@8.1.0", "", { "dependencies": { "@vis.gl/react-mapbox": "8.1.0", "@vis.gl/react-maplibre": "8.1.0" }, "peerDependencies": { "mapbox-gl": ">=1.13.0", "maplibre-gl": ">=1.13.0", "react": ">=16.3.0", "react-dom": ">=16.3.0" }, "optionalPeers": ["mapbox-gl", "maplibre-gl"] }, "sha512-vDx/QXR3Tb+8/ap/z6gdMjJQ8ZEyaZf6+uMSPz7jhWF5VZeIsKsGfPvwHVPPwGF43Ryn+YR4bd09uEFNR5OPdg=="], @@ -1230,6 +1224,8 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "webfft": ["webfft@1.0.3", "", {}, "sha512-Xi9V1yiKuxJvfo9TysWs17C4++7c62KSr1uS9WKj6o3ZMVJdxkoT301qltym0hZxvSU0+8tWuGF1FHNdeP158g=="], + "webpack": ["webpack@5.102.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.26.3", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ=="], "webpack-bundle-analyzer": ["webpack-bundle-analyzer@4.10.2", "", { "dependencies": { "@discoveryjs/json-ext": "0.5.7", "acorn": "^8.0.4", "acorn-walk": "^8.0.0", "commander": "^7.2.0", "debounce": "^1.2.1", "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", "html-escaper": "^2.0.2", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^2.0.3", "ws": "^7.3.1" }, "bin": { "webpack-bundle-analyzer": "lib/bin/analyzer.js" } }, "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw=="], diff --git a/electron/main.ts b/electron/main.ts index 35348e2..504f153 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, shell, nativeImage } from 'electron'; +import { app, BrowserWindow, ipcMain, shell, nativeImage, Tray, Menu } from 'electron'; import { autoUpdater } from 'electron-updater'; import serve from 'electron-serve'; import path from 'path'; @@ -9,12 +9,18 @@ const loadURL = serve({ scheme: 'app' }); +const gotTheLock = app.requestSingleInstanceLock(); + +if (!gotTheLock) { + app.quit(); +} + const setupDock = () => { if (process.platform === 'darwin' && app.dock) { - const iconPath = isProd + const iconPath = isProd ? path.join(process.resourcesPath, 'icons', 'app.png') : path.join(app.getAppPath(), 'public', 'icons', 'app.png'); - + try { const dockIcon = nativeImage.createFromPath(iconPath); if (!dockIcon.isEmpty()) { @@ -27,7 +33,66 @@ const setupDock = () => { } }; +const createTray = () => { + const iconPath = isProd + ? path.join(process.resourcesPath, 'icons', process.platform === 'win32' ? 'app.ico' : 'app.png') + : path.join(app.getAppPath(), 'public', 'icons', process.platform === 'win32' ? 'app.ico' : 'app.png'); + + tray = new Tray(iconPath); + + const contextMenu = Menu.buildFromTemplate([ + { + label: '顯示視窗', + click: () => { + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + mainWindow.focus(); + } + } + }, + { + label: '隱藏視窗', + click: () => { + if (mainWindow) { + mainWindow.hide(); + } + } + }, + { type: 'separator' }, + { + label: '退出', + click: () => { + isQuitting = true; + app.quit(); + } + } + ]); + + tray.setToolTip('EQ RTS Map'); + tray.setContextMenu(contextMenu); + + tray.on('click', () => { + if (mainWindow) { + if (mainWindow.isVisible()) { + mainWindow.hide(); + } else { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.show(); + mainWindow.focus(); + } + } + }); +}; + let mainWindow: BrowserWindow | null; +let tray: Tray | null = null; let isQuitting = false; const createMainWindow = async (): Promise => { @@ -125,10 +190,26 @@ autoUpdater.on('error', (err) => { } }); +app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + + mainWindow.focus(); + mainWindow.moveTop(); + } +}); + (async () => { await app.whenReady(); setupDock(); + createTray(); ipcMain.handle('get-app-version', () => { return app.getVersion(); @@ -258,7 +339,7 @@ autoUpdater.on('error', (err) => { })(); app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { + if (!tray && process.platform !== 'darwin') { app.quit(); } }); @@ -271,6 +352,11 @@ app.on('will-quit', () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.removeAllListeners(); } + + if (tray) { + tray.destroy(); + tray = null; + } }); ipcMain.handle('force-quit', async () => { diff --git a/package.json b/package.json index fa82646..6a19abe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eq-rts-map", - "version": "1.0.0-beta.13", + "version": "1.0.0-rc.2", "description": "EQ RTS Map", "author": "ExpTech Studio ", "type": "module", @@ -20,12 +20,11 @@ "dependencies": { "@electron/remote": "^2.1.3", "electron-serve": "^3.0.0", - "electron-updater": "^6.6.2" + "electron-updater": "^6.6.2", + "webfft": "^1.0.3" }, "devDependencies": { "@radix-ui/react-slot": "^1.2.3", - "electronmon": "^2.0.3", - "wait-on": "^9.0.1", "@tailwindcss/postcss": "^4.1.14", "@types/bun": "latest", "@types/node": "^24.8.1", @@ -40,13 +39,13 @@ "cross-env": "^10.1.0", "electron": "^38.3.0", "electron-builder": "^26.0.12", + "electronmon": "^2.0.3", "lucide-react": "^0.546.0", "maplibre-gl": "^5.9.0", - "next": "^15.5.5", + "next": "15.5.7", "next-themes": "^0.4.6", "postcss": "^8.5.6", "react": "^19.2.0", - "react-chartjs-2": "^5.3.0", "react-dom": "^19.2.0", "react-map-gl": "^8.1.0", "tailwind-merge": "^3.3.1", @@ -56,10 +55,11 @@ "ts-node": "^10.9.2", "tsconfig-paths-webpack-plugin": "^4.2.0", "typescript": "^5.9.3", + "wait-on": "^9.0.1", "webpack": "^5.102.1", "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1", "webpack-node-externals": "^3.0.0" } -} +} \ No newline at end of file diff --git a/public/chart-worker.js b/public/chart-worker.js index 2b4617a..0e67816 100644 --- a/public/chart-worker.js +++ b/public/chart-worker.js @@ -1,440 +1,170 @@ -const DISPLAY_DURATION = 60; const STATION_IDS = [4812424, 6126556, 11336952, 11334880, 1480496]; -const CHART_LENGTH = 50 * DISPLAY_DURATION; - +const CHART_LENGTH = 3000; const TOTAL_HEIGHT = 630; -const NUM_CHANNELS = 5; -const TOP_BOTTOM_GAP_REDUCTION = 50; -const CHANNEL_LABEL_OFFSETS = [30, 45, 50, 60, 70]; - -const BASE_GAP = TOTAL_HEIGHT / (NUM_CHANNELS + 1); -const TOP_GAP = BASE_GAP - TOP_BOTTOM_GAP_REDUCTION; -const MIDDLE_GAP_EXTRA = (TOP_BOTTOM_GAP_REDUCTION * 2) / 4; -const MIDDLE_GAP = BASE_GAP + MIDDLE_GAP_EXTRA; - -class SOSStage { - constructor(b0, b1, b2, a1, a2) { - this.b0 = b0; - this.b1 = b1; - this.b2 = b2; - this.a1 = a1; - this.a2 = a2; - this.z1 = 0; - this.z2 = 0; - } -} - -class BPFFilter { - constructor(num, den) { - if (num.length !== den.length) { - throw new Error('num/den length mismatch'); - } - - this.stages = num.map((numCoeffs, i) => { - const [b0, b1, b2] = numCoeffs; - const [a0, a1, a2] = den[i]; - - if (a0 !== 1.0) { - return new SOSStage(b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0); - } - - return new SOSStage(b0, b1, b2, a1, a2); - }); - } - - apply(x) { - let y = x; - for (let i = 0; i < this.stages.length; i++) { - const stage = this.stages[i]; - const out = stage.b0 * y + stage.z1; - stage.z1 = stage.b1 * y - stage.a1 * out + stage.z2; - stage.z2 = stage.b2 * y - stage.a2 * out; - y = out; - } - return y; - } - - applyBuffer(x) { - return x.map((val) => this.apply(val)); - } - - reset() { - for (const stage of this.stages) { - stage.z1 = 0; - stage.z2 = 0; - } - } -} +const BASE_GAP = TOTAL_HEIGHT / 6; +const TOP_GAP = BASE_GAP - 50; +const MIDDLE_GAP = BASE_GAP + 25; +// Filter coefficients const NUM_LPF = [ - [0.8063260828207, 0, 0], - [1, -0.3349099821478, 1], - [0.8764452158503, 0, 0], - [1, -0.08269016387548, 1], - [0.8131516681065, 0, 0], - [1, 0.5521204464881, 1], - [1.228277124762, 0, 0], - [1, 1.705652561121, 1], - [0.00431639855615, 0, 0], - [1, -0.4218227257396, 1], - [1, 0, 0], + [0.8063260828207, 0, 0], [1, -0.3349099821478, 1], + [0.8764452158503, 0, 0], [1, -0.08269016387548, 1], + [0.8131516681065, 0, 0], [1, 0.5521204464881, 1], + [1.228277124762, 0, 0], [1, 1.705652561121, 1], + [0.00431639855615, 0, 0], [1, -0.4218227257396, 1], [1, 0, 0], ]; - const DEN_LPF = [ - [1, 0, 0], - [1, -0.6719798550872, 0.938845023254], - [1, 0, 0], - [1, -0.8264759910073, 0.8561761588872], - [1, 0, 0], - [1, -1.10962299915, 0.7141202529829], - [1, 0, 0], - [1, -1.413006561919, 0.5638384962434], - [1, 0, 0], - [1, -0.6139497794955, 0.9834048810788], - [1, 0, 0], + [1, 0, 0], [1, -0.6719798550872, 0.938845023254], + [1, 0, 0], [1, -0.8264759910073, 0.8561761588872], + [1, 0, 0], [1, -1.10962299915, 0.7141202529829], + [1, 0, 0], [1, -1.413006561919, 0.5638384962434], + [1, 0, 0], [1, -0.6139497794955, 0.9834048810788], [1, 0, 0], ]; - const NUM_HPF = [ - [0.9769037485204, 0, 0], - [1, -2, 1], - [0.9424328308459, 0, 0], - [1, -2, 1], - [0.9149691441131, 0, 0], - [1, -2, 1], - [0.8959987277275, 0, 0], - [1, -2, 1], - [0.8863374802187, 0, 0], - [1, -2, 1], - [1, 0, 0], + [0.9769037485204, 0, 0], [1, -2, 1], + [0.9424328308459, 0, 0], [1, -2, 1], + [0.9149691441131, 0, 0], [1, -2, 1], + [0.8959987277275, 0, 0], [1, -2, 1], + [0.8863374802187, 0, 0], [1, -2, 1], [1, 0, 0], ]; - const DEN_HPF = [ - [1, 0, 0], - [1, -1.946073828052, 0.9615411660298], - [1, 0, 0], - [1, -1.877404882092, 0.8923264412918], - [1, 0, 0], - [1, -1.822694925196, 0.837181651256], - [1, 0, 0], - [1, -1.78490427193, 0.7990906389804], - [1, 0, 0], - [1, -1.765658260281, 0.7796916605933], - [1, 0, 0], + [1, 0, 0], [1, -1.946073828052, 0.9615411660298], + [1, 0, 0], [1, -1.877404882092, 0.8923264412918], + [1, 0, 0], [1, -1.822694925196, 0.837181651256], + [1, 0, 0], [1, -1.78490427193, 0.7990906389804], + [1, 0, 0], [1, -1.765658260281, 0.7796916605933], [1, 0, 0], ]; -const MAX_FILTER_CACHE_SIZE = 10; +// Filter cache const filterCache = new Map(); -const filterAccessOrder = []; -function getBPFFilter(stationId) { - if (!filterCache.has(stationId)) { - if (filterCache.size >= MAX_FILTER_CACHE_SIZE) { - const oldestStationId = filterAccessOrder.shift(); - if (oldestStationId !== undefined) { - filterCache.delete(oldestStationId); - } - } - - const hpf = new BPFFilter(NUM_HPF, DEN_HPF); - const lpf = new BPFFilter(NUM_LPF, DEN_LPF); - filterCache.set(stationId, { hpf, lpf }); - } - - const index = filterAccessOrder.indexOf(stationId); - if (index > -1) { - filterAccessOrder.splice(index, 1); - } - filterAccessOrder.push(stationId); - - return filterCache.get(stationId); -} - -function clearUnusedFilters(activeStationIds) { - const activeSet = new Set(activeStationIds); - const toDelete = []; - - filterCache.forEach((_, stationId) => { - if (!activeSet.has(stationId)) { - toDelete.push(stationId); - } +function createFilter(num, den) { + const stages = num.map((n, i) => { + const [b0, b1, b2] = n; + const [a0, a1, a2] = den[i]; + const k = a0 !== 1 ? 1 / a0 : 1; + return { b0: b0 * k, b1: b1 * k, b2: b2 * k, a1: a1 * k, a2: a2 * k, z1: 0, z2: 0 }; }); - - toDelete.forEach(stationId => { - filterCache.delete(stationId); - const index = filterAccessOrder.indexOf(stationId); - if (index > -1) { - filterAccessOrder.splice(index, 1); + return (x) => { + for (const s of stages) { + const out = s.b0 * x + s.z1; + s.z1 = s.b1 * x - s.a1 * out + s.z2; + s.z2 = s.b2 * x - s.a2 * out; + x = out; } - }); + return x; + }; } -function applyBPF(data, stationId) { - if (!data || data.length === 0) return data; - - const { hpf, lpf } = getBPFFilter(stationId); - const result = []; - - for (let i = 0; i < data.length; i++) { - const value = data[i]; - - if (value === null || value === undefined) { - result.push(null); - } else { - const hpfValue = hpf.apply(value); - const filteredValue = lpf.apply(hpfValue); - result.push(filteredValue); - } +function getFilters(stationId) { + if (!filterCache.has(stationId)) { + filterCache.set(stationId, { + hpf: createFilter(NUM_HPF, DEN_HPF), + lpf: createFilter(NUM_LPF, DEN_LPF) + }); } - - return result; + return filterCache.get(stationId); } -function generateColorFromId(id) { - let hash = 0; - const str = id.toString(); - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - - hash = Math.abs(hash); - - const hue = hash % 360; - const saturation = 85 + (hash % 15); - const lightness = 50 + (hash % 10); - - const h = hue / 360; - const s = saturation / 100; - const l = lightness / 100; - +function hslToRgb(h, s, l) { const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs((h * 6) % 2 - 1)); const m = l - c / 2; - let r, g, b; - if (h < 1/6) { - r = c; g = x; b = 0; - } else if (h < 2/6) { - r = x; g = c; b = 0; - } else if (h < 3/6) { - r = 0; g = c; b = x; - } else if (h < 4/6) { - r = 0; g = x; b = c; - } else if (h < 5/6) { - r = x; g = 0; b = c; - } else { - r = c; g = 0; b = x; - } - - r = Math.round((r + m) * 255); - g = Math.round((g + m) * 255); - b = Math.round((b + m) * 255); - - return `rgb(${r}, ${g}, ${b})`; + if (h < 1/6) { r = c; g = x; b = 0; } + else if (h < 2/6) { r = x; g = c; b = 0; } + else if (h < 3/6) { r = 0; g = c; b = x; } + else if (h < 4/6) { r = 0; g = x; b = c; } + else if (h < 5/6) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + return `rgb(${Math.round((r + m) * 255)}, ${Math.round((g + m) * 255)}, ${Math.round((b + m) * 255)})`; } -function generateTimeLabels(length, sampleRate) { - return Array.from({ length }, (_, i) => { - const position = length - i; - const timeInSeconds = position / sampleRate; - const interval = sampleRate * 10; - const offset = sampleRate * 5; - - if (position % interval === offset && timeInSeconds > 0 && timeInSeconds <= 60) { - return timeInSeconds.toString(); - } - return ''; - }); -} - -function generateChannelConfigs() { - return [ - { baseline: TOTAL_HEIGHT - TOP_GAP, color: generateColorFromId(STATION_IDS[0]) }, - { baseline: TOTAL_HEIGHT - TOP_GAP - MIDDLE_GAP, color: generateColorFromId(STATION_IDS[1]) }, - { baseline: TOTAL_HEIGHT - TOP_GAP - (MIDDLE_GAP * 2), color: generateColorFromId(STATION_IDS[2]) }, - { baseline: TOTAL_HEIGHT - TOP_GAP - (MIDDLE_GAP * 3), color: generateColorFromId(STATION_IDS[3]) }, - { baseline: TOTAL_HEIGHT - TOP_GAP - (MIDDLE_GAP * 4), color: generateColorFromId(STATION_IDS[4]) }, - ]; +function idToColor(id) { + let hash = 0; + for (const c of String(id)) hash = ((hash << 5) - hash + c.charCodeAt(0)) | 0; + hash = Math.abs(hash); + return hslToRgb((hash % 360) / 360, (85 + hash % 15) / 100, (50 + hash % 10) / 100); } -function processWaveformData(waveformData, stationConfigs) { - const channelDataArrays = []; - const channelConfigs = generateChannelConfigs(); - - channelConfigs.forEach((config, index) => { - let data; - - if (index < STATION_IDS.length) { - const stationId = STATION_IDS[index]; - const stationConfig = stationConfigs[stationId]; - - if (!stationConfig) { - data = Array(CHART_LENGTH).fill(null); - } else { - let stationWaveform = waveformData[stationId] || Array(stationConfig.dataLength).fill(null); - - stationWaveform = applyBPF(stationWaveform, stationId); - - if (stationConfig.sampleRate === 20) { - data = []; - for (let i = 0; i < stationWaveform.length; i++) { - const value = stationWaveform[i]; - if (value !== null) { - const scaledValue = (value * stationConfig.scale) + config.baseline; - data.push(scaledValue); - data.push(scaledValue); - if (i % 2 === 0) data.push(scaledValue); - } else { - data.push(null); - data.push(null); - if (i % 2 === 0) data.push(null); - } - } - } else { - data = stationWaveform.map(value => - value !== null ? (value * stationConfig.scale) + config.baseline : null - ); - } - - while (data.length < CHART_LENGTH) { - data.unshift(null); - } - while (data.length > CHART_LENGTH) { - data.shift(); - } - } - } else { - data = Array(CHART_LENGTH).fill(null); +const CHANNEL_CONFIGS = STATION_IDS.map((id, i) => ({ + baseline: TOTAL_HEIGHT - TOP_GAP - MIDDLE_GAP * i, + color: idToColor(id) +})); + +const TIME_LABELS = (() => { + const labels = []; + for (let i = 0; i < CHART_LENGTH; i++) { + const pos = CHART_LENGTH - i; + if (pos % 500 === 250 && pos > 0) { + labels.push({ x: i, text: String(pos / 50) }); } + } + return labels; +})(); - channelDataArrays.push({ index, data }); - }); - - return channelDataArrays; -} - -function calculateChannelMaxValues(channelDataArrays) { - const channelConfigs = generateChannelConfigs(); - const channelMaxValues = []; +function processChartDataForCanvas(waveformData, stationConfigs) { + const channels = STATION_IDS.map((stationId, index) => { + const config = CHANNEL_CONFIGS[index]; + const stationConfig = stationConfigs[stationId]; - channelDataArrays.forEach(({index, data}) => { - const config = channelConfigs[index]; - let maxAbsDeviation = 0; + if (!stationConfig) { + return { stationId, baseline: config.baseline, color: config.color, points: new Float32Array(CHART_LENGTH).fill(NaN), order: 0 }; + } - data.forEach(value => { - if (value !== null) { - const deviation = Math.abs(value - config.baseline); - maxAbsDeviation = Math.max(maxAbsDeviation, deviation); + const raw = waveformData[stationId] || []; + const { hpf, lpf } = getFilters(stationId); + const { scale, sampleRate } = stationConfig; + const is20Hz = sampleRate === 20; + const points = new Float32Array(CHART_LENGTH); + points.fill(NaN); + + let writeIdx = CHART_LENGTH; + for (let i = raw.length - 1; i >= 0 && writeIdx > 0; i--) { + const v = raw[i]; + const scaled = v != null ? lpf(hpf(v)) * scale + config.baseline : NaN; + const repeat = is20Hz ? (i % 2 === 0 ? 3 : 2) : 1; + for (let r = 0; r < repeat && writeIdx > 0; r++) { + points[--writeIdx] = scaled; } - }); - - channelMaxValues.push({ index, maxAbsDeviation }); - }); - - channelMaxValues.sort((a, b) => a.maxAbsDeviation - b.maxAbsDeviation); + } - const indexToOrder = {}; - channelMaxValues.forEach((item, order) => { - indexToOrder[item.index] = order; + return { stationId, baseline: config.baseline, color: config.color, points, order: 0 }; }); - return indexToOrder; -} - -function generateChartDatasets(channelDataArrays, indexToOrder) { - const channelConfigs = generateChannelConfigs(); - const datasets = []; - - channelDataArrays.forEach(({index, data}) => { - const config = channelConfigs[index]; - const orderRank = indexToOrder[index] || 0; - const baseOrder = orderRank * 2; - - datasets.push({ - label: `Station ${STATION_IDS[index] || index} (White)`, - data: data, - borderColor: 'rgba(255, 255, 255, 0.3)', - backgroundColor: 'transparent', - borderWidth: 0.8, - pointRadius: 0, - tension: 0, - fill: false, - spanGaps: false, - order: baseOrder, - }); - - datasets.push({ - label: `Station ${STATION_IDS[index] || index}`, - data: data, - borderColor: config.color, - backgroundColor: 'transparent', - borderWidth: 1.5, - pointRadius: 0, - tension: 0, - fill: false, - spanGaps: false, - order: baseOrder, - }); + // Calculate order by max deviation + const deviations = channels.map((ch, idx) => { + let max = 0; + for (let i = 0; i < ch.points.length; i++) { + const v = ch.points[i]; + if (!Number.isNaN(v)) max = Math.max(max, Math.abs(v - ch.baseline)); + } + return { idx, max }; }); + deviations.sort((a, b) => a.max - b.max); + deviations.forEach((d, order) => { channels[d.idx].order = order; }); - return datasets; -} - -function processChartData(waveformData, stationConfigs) { - const activeStationIds = Object.keys(waveformData).map(id => parseInt(id)); - clearUnusedFilters(activeStationIds); - - const channelDataArrays = processWaveformData(waveformData, stationConfigs); - const indexToOrder = calculateChannelMaxValues(channelDataArrays); - const datasets = generateChartDatasets(channelDataArrays, indexToOrder); - const timeLabels = generateTimeLabels(CHART_LENGTH, 50); - - return { - labels: timeLabels, - datasets: datasets, - }; + return { channels, timeLabels: TIME_LABELS }; } self.onmessage = function(e) { const { type, data } = e.data; - try { switch (type) { - case 'PROCESS_CHART_DATA': - const chartData = processChartData(data.waveformData, data.stationConfigs); - self.postMessage({ - type: 'CHART_DATA_SUCCESS', - data: chartData, - }); + case 'PROCESS_CHART_DATA_CANVAS': + const result = processChartDataForCanvas(data.waveformData, data.stationConfigs); + self.postMessage( + { type: 'CHART_DATA_CANVAS_SUCCESS', data: result }, + result.channels.map(ch => ch.points.buffer) + ); break; - - case 'GENERATE_TIME_LABELS': - const timeLabels = generateTimeLabels(data.length, data.sampleRate); - self.postMessage({ - type: 'TIME_LABELS_SUCCESS', - data: timeLabels, - }); - break; - case 'GENERATE_CHANNEL_CONFIGS': - const channelConfigs = generateChannelConfigs(); - self.postMessage({ - type: 'CHANNEL_CONFIGS_SUCCESS', - data: channelConfigs, - }); + self.postMessage({ type: 'CHANNEL_CONFIGS_SUCCESS', data: CHANNEL_CONFIGS }); break; - default: - self.postMessage({ - type: 'ERROR', - error: 'Unknown message type', - }); + self.postMessage({ type: 'ERROR', error: 'Unknown message type' }); } } catch (error) { - self.postMessage({ - type: 'ERROR', - error: error.message, - }); + self.postMessage({ type: 'ERROR', error: error.message }); } }; diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx index f36bf4f..e12ca88 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -1,19 +1,25 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; import MapSection from '@/components/MapSection'; import ChartSection from '@/components/ChartSection'; import AlertManager from '@/components/AlertManager'; +import Footer from '@/components/footer'; import { RTSProvider } from '@/contexts/RTSContext'; +type DisplayMode = 'waveform' | 'spectrogram'; + export default function Home() { + const [displayMode, setDisplayMode] = useState('waveform'); + return (
- + +
); -} \ No newline at end of file +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fc3ce..548e43e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next' import './globals.css' import { ThemeProvider } from '@/components/ThemeProvider'; -import Footer from '@/components/footer'; export const metadata: Metadata = { title: 'EQ RTS MAP', @@ -23,7 +22,6 @@ export default function RootLayout({ disableTransitionOnChange > {children} -