feat: implement comprehensive map editing system with advanced geometry tools, replay management, and project session state modules.
Build and Release / release (push) Successful in 55s

This commit is contained in:
taDuc
2026-06-11 22:05:18 +07:00
parent 0d6599015b
commit 5a8dfc4b50
180 changed files with 43408 additions and 119 deletions
-28
View File
@@ -19,34 +19,6 @@ const nextConfig: NextConfig = {
],
},
output: 'standalone',
async rewrites() {
// Proxy backend API calls through Next.js to avoid browser CORS issues.
//
// Configure the target via:
// - API_PROXY_TARGET (server-side, recommended) e.g. http://localhost:8080
// - NEXT_PUBLIC_API_URL_ROOT (fallback)
const target =
process.env.API_PROXY_TARGET ||
process.env.NEXT_PUBLIC_API_URL_ROOT ||
"https://history-api.kain.id.vn";
const prefixes = [
"auth",
"users",
"media",
"projects",
"submissions",
"statistics",
"roles",
"historian",
];
return [
...prefixes.map((p) => ({
source: `/${p}/:path*`,
destination: `${target}/${p}/:path*`,
})),
];
},
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
+440 -28
View File
@@ -23,7 +23,10 @@
"autoprefixer": "^10.4.22",
"axios": "^1.14.0",
"flatpickr": "^4.6.13",
"maplibre-gl": "^5.24.0",
"next": "^16.1.6",
"polylabel": "^2.0.1",
"quill-blot-formatter": "^1.0.5",
"react": "^19.2.0",
"react-apexcharts": "^1.8.0",
"react-dnd": "^16.0.1",
@@ -36,12 +39,15 @@
"sweetalert2": "^11.26.24",
"swiper": "^11.2.10",
"tailwind-merge": "^2.6.0",
"yet-another-react-lightbox": "^3.30.1"
"uuid": "^13.0.2",
"yet-another-react-lightbox": "^3.30.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@svgr/webpack": "^8.1.0",
"@types/node": "^20.19.25",
"@types/polylabel": "^1.1.3",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@types/react-transition-group": "^4.4.12",
@@ -2564,6 +2570,118 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/mlt": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.11.tgz",
"integrity": "sha512-dKvjKdITw9d0y3ndGkSqLUEpWCizMtdq8NB06cHohH/JZ2sJoM7dClR9wzJLUWykjbw9RXDFmhjjNBnNW27mzw==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz",
"integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -3494,6 +3612,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3519,6 +3643,13 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/polylabel": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz",
"integrity": "sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -4622,7 +4753,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
@@ -4654,7 +4784,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4739,6 +4868,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -5017,6 +5155,26 @@
}
}
},
"node_modules/deep-equal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
"license": "MIT",
"dependencies": {
"is-arguments": "^1.1.1",
"is-date-object": "^1.0.5",
"is-regex": "^1.1.4",
"object-is": "^1.1.5",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.5.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5038,7 +5196,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
@@ -5056,7 +5213,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
@@ -5205,6 +5361,12 @@
"node": ">= 0.4"
}
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/electron-to-chromium": {
"version": "1.5.329",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz",
@@ -5883,9 +6045,15 @@
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
@@ -6135,7 +6303,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -6229,6 +6396,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -6317,7 +6490,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
@@ -6469,6 +6641,22 @@
"node": ">= 0.4"
}
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -6621,7 +6809,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -6750,7 +6937,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -6999,6 +7185,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -7028,6 +7220,12 @@
"node": ">=4.0"
}
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7418,6 +7616,40 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/maplibre-gl": {
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7505,7 +7737,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -7518,6 +7749,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -7711,11 +7948,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -7880,9 +8132,9 @@
}
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/parent-module": {
@@ -7954,6 +8206,18 @@
"node": ">=8"
}
},
"node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7973,6 +8237,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/polylabel": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/polylabel/-/polylabel-2.0.1.tgz",
"integrity": "sha512-B6Yu+Bdl/8SGtjVhyUfZzD3DwciCS9SPVtHiNdt8idHHatvTHp5Ss8XGDRmQFtfF1ZQnfK+Cj5dXdpkUXBbXgA==",
"license": "ISC",
"dependencies": {
"tinyqueue": "^3.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -8018,6 +8291,12 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
@@ -8049,6 +8328,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -8089,19 +8374,45 @@
],
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
}
},
"node_modules/quill-blot-formatter": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/quill-blot-formatter/-/quill-blot-formatter-1.0.5.tgz",
"integrity": "sha512-iVmuEdmMIpvERBnnDfosWul6VAVN6tqQRruUzAEwA9ZbQ/Ef7DTHGZDUR4KklXpxM+z50opFp6m1NhNdN6HJhw==",
"license": "Apache-2.0",
"dependencies": {
"deepmerge": "^2.0.0"
},
"peerDependencies": {
"quill": "^1.3.4"
}
},
"node_modules/quill-blot-formatter/node_modules/deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
"license": "MIT",
"engines": {
"npm": ">=8.2.3"
"node": ">=0.10.0"
}
},
"node_modules/quill-delta": {
@@ -8119,6 +8430,26 @@
"node": ">= 12.0.0"
}
},
"node_modules/quill/node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/quill/node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -8232,6 +8563,33 @@
"react-dom": "^16 || ^17 || ^18 || ^19"
}
},
"node_modules/react-quill-new/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/react-quill-new/node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/react-quill-new/node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
@@ -8319,7 +8677,6 @@
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
@@ -8421,6 +8778,15 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -8541,7 +8907,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
@@ -8559,7 +8924,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
@@ -9120,6 +9484,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -9462,6 +9832,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/uuid": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -9646,6 +10029,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}
+7 -1
View File
@@ -25,7 +25,10 @@
"autoprefixer": "^10.4.22",
"axios": "^1.14.0",
"flatpickr": "^4.6.13",
"maplibre-gl": "^5.24.0",
"next": "^16.1.6",
"polylabel": "^2.0.1",
"quill-blot-formatter": "^1.0.5",
"react": "^19.2.0",
"react-apexcharts": "^1.8.0",
"react-dnd": "^16.0.1",
@@ -38,12 +41,15 @@
"sweetalert2": "^11.26.24",
"swiper": "^11.2.10",
"tailwind-merge": "^2.6.0",
"yet-another-react-lightbox": "^3.30.1"
"uuid": "^13.0.2",
"yet-another-react-lightbox": "^3.30.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@svgr/webpack": "^8.1.0",
"@types/node": "^20.19.25",
"@types/polylabel": "^1.1.3",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@types/react-transition-group": "^4.4.12",
@@ -197,7 +197,7 @@ export default function Page() {
};
const handleViewDetails = (id: string) => {
router.push(`/submissions/${id}`);
window.open(`/submissions/${id}`, "_blank");
};
const handleOpenActionModal = (item: SubmissionItem) => {
@@ -0,0 +1,813 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useShallow } from "zustand/react/shallow";
import { toast } from "sonner";
import Map from "@/uhm/components/Map";
import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
import TimelineBar from "@/uhm/components/ui/TimelineBar";
import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel";
import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel";
import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel";
import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel";
import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel";
import { fetchProjectCommits } from "@/uhm/api/projects";
import { requestJson } from "@/uhm/api/http";
import { API_ENDPOINTS } from "@/uhm/api/config";
import { normalizeEditorSnapshot, getDefaultTypeIdForFeature, normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import { isFeatureVisibleAtYear } from "@/uhm/lib/editor/editorPageUtils";
import { buildEntityLabelContextDraft as buildPreviewEntityLabelContextDraft } from "@/uhm/lib/preview/relationIndex";
import { getDirectGeometryChildIds } from "@/uhm/lib/editor/geometry/geometryBinding";
import { ResizeHandle } from "@/uhm/components/ui/ResizeHandle";
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/map/geo/constants";
import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline";
import { loadBackgroundLayerVisibilityFromStorage } from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
import { apiGetSubmissionDetail, updateSubmission } from "@/service/submisisonService";
import { SubmissionItem } from "@/components/tables/SubmissionsTable";
import {
EditorStoreProvider,
useEditorStore,
useEditorStoreApi,
} from "@/uhm/store/editorStore";
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, ProjectCommit, EntityWikiLinkSnapshot, Project } from "@/uhm/types/projects";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki";
const CURRENT_YEAR = new Date().getUTCFullYear();
const DEFAULT_EDITOR_USER_ID = "admin-viewer";
// Helper functions copied from useProjectCommands.ts to build read-only session snapshot.
function toEditorSessionSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
return {
...snapshot,
entities: toEditorSessionEntities(snapshot.entities),
geometries: toEditorSessionGeometries(snapshot.geometries),
geometry_entity: toEditorSessionGeometryEntity(snapshot.geometry_entity),
wikis: toEditorSessionWikis(snapshot.wikis),
entity_wiki: toEditorSessionEntityWikiLinks(snapshot.entity_wiki),
};
}
function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnapshot[] {
const rows = Array.isArray(input) ? input : [];
return rows
.filter((e): e is any => Boolean(e) && (typeof e.id === "string" || typeof e.id === "number"))
.filter((e) => e.operation !== "delete")
.map((e) => {
const id = String(e.id);
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
return {
id,
source,
operation: "reference",
name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description ?? null,
time_start: normalizeTimelineYearValue(e.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(e.time_end) ?? undefined,
};
});
}
function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): GeometrySnapshot[] {
const rows = Array.isArray(input) ? input : [];
return rows
.filter((g): g is any => Boolean(g) && (typeof g.id === "string" || typeof g.id === "number"))
.filter((g) => g.operation !== "delete")
.map((g) => {
const id = String(g.id);
const source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref";
return {
id,
source,
operation: "reference",
type: g.type ?? undefined,
draw_geometry: g.draw_geometry,
geometry: g.geometry,
bound_with: g.bound_with ?? null,
time_start: normalizeTimelineYearValue(g.time_start) ?? undefined,
time_end: normalizeTimelineYearValue(g.time_end) ?? undefined,
bbox: g.bbox
? {
min_lng: g.bbox.min_lng,
min_lat: g.bbox.min_lat,
max_lng: g.bbox.max_lng,
max_lat: g.bbox.max_lat,
}
: g.bbox ?? undefined,
};
});
}
function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"]): GeometryEntitySnapshot[] {
const rows = Array.isArray(input) ? input : [];
const deduped = new globalThis.Map<string, GeometryEntitySnapshot>();
for (const row of rows) {
if (!row) continue;
const safeRow = row as any;
if (safeRow.operation === "delete") continue;
const geometry_id = typeof safeRow.geometry_id === "string" || typeof safeRow.geometry_id === "number"
? String(safeRow.geometry_id).trim()
: "";
const entity_id = typeof safeRow.entity_id === "string" || typeof safeRow.entity_id === "number"
? String(safeRow.entity_id).trim()
: "";
if (!geometry_id || !entity_id) continue;
const key = `${geometry_id}::${entity_id}`;
deduped.set(key, {
geometry_id,
entity_id,
operation: "reference",
});
}
return Array.from(deduped.values()).sort((a, b) => {
const g = a.geometry_id.localeCompare(b.geometry_id);
if (g !== 0) return g;
return a.entity_id.localeCompare(b.entity_id);
});
}
function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
const rows = Array.isArray(input) ? input : [];
return rows
.filter((w): w is any => Boolean(w) && typeof w.id === "string" && w.id.trim().length > 0)
.filter((w) => w.operation !== "delete")
.map((w) => {
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
return {
id: w.id,
source,
operation: "reference",
title: typeof w.title === "string" ? w.title : "",
slug: w.slug ?? null,
doc: w.doc ?? null,
};
});
}
function toEditorSessionEntityWikiLinks(input: EditorSnapshot["entity_wiki"]): EntityWikiLinkSnapshot[] {
const rows = Array.isArray(input) ? input : [];
const deduped = new globalThis.Map<string, EntityWikiLinkSnapshot>();
for (const row of rows) {
if (!row) continue;
const safeRow = row as any;
if (safeRow.operation === "delete") continue;
const entity_id = typeof safeRow.entity_id === "string" || typeof safeRow.entity_id === "number"
? String(safeRow.entity_id).trim()
: "";
const wiki_id = typeof safeRow.wiki_id === "string" || typeof safeRow.wiki_id === "number"
? String(safeRow.wiki_id).trim()
: "";
if (!entity_id || !wiki_id) continue;
const key = `${entity_id}::${wiki_id}`;
deduped.set(key, {
entity_id,
wiki_id,
operation: "reference",
});
}
return Array.from(deduped.values()).sort((a, b) => {
const e = a.entity_id.localeCompare(b.entity_id);
if (e !== 0) return e;
return a.wiki_id.localeCompare(b.wiki_id);
});
}
export default function SubmissionDetailPage() {
return (
<EditorStoreProvider
options={{
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
fallbackTimelineRange: FIXED_TIMELINE_RANGE,
currentYear: CURRENT_YEAR,
}}
>
<SubmissionDetailPageContent />
</EditorStoreProvider>
);
}
// Side Component to set the Zustand store state based on loaded data.
function StoreInitializer({
project,
sessionSnapshot,
}: {
project: Project;
sessionSnapshot: EditorSnapshot;
}) {
const store = useEditorStoreApi();
useEffect(() => {
if (!project || !sessionSnapshot) return;
const state = store.getState();
state.setActiveSection(project);
state.setSelectedProjectId(project.id);
state.setBaselineSnapshot(sessionSnapshot);
state.setBaselineFeatureCollection(sessionSnapshot?.editor_feature_collection || EMPTY_FEATURE_COLLECTION);
state.setSnapshotEntityRows(sessionSnapshot?.entities || []);
state.setSnapshotWikis(sessionSnapshot?.wikis || []);
state.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
state.setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
state.setIsBackgroundVisibilityReady(true);
// Auto-detect the earliest year from geometries or entities to center the timeline.
let minYear: number | null = null;
if (sessionSnapshot?.geometries && sessionSnapshot.geometries.length > 0) {
for (const g of sessionSnapshot.geometries) {
if (g.time_start !== undefined && g.time_start !== null) {
const y = Number(g.time_start);
if (!Number.isNaN(y)) {
if (minYear === null || y < minYear) {
minYear = y;
}
}
}
}
}
if (minYear === null && sessionSnapshot?.entities && sessionSnapshot.entities.length > 0) {
for (const e of sessionSnapshot.entities) {
if (e.time_start !== undefined && e.time_start !== null) {
const y = Number(e.time_start);
if (!Number.isNaN(y)) {
if (minYear === null || y < minYear) {
minYear = y;
}
}
}
}
}
if (minYear !== null) {
state.setTimelineDraftYear(clampYearToFixedRange(minYear));
}
}, [project, sessionSnapshot, store]);
return null;
}
function clampNumber(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
function SubmissionDetailPageContent() {
const params = useParams();
const router = useRouter();
const id = String(params.id || "");
const [submission, setSubmission] = useState<SubmissionItem | null>(null);
const [project, setProject] = useState<Project | null>(null);
const [sessionSnapshot, setSessionSnapshot] = useState<EditorSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reviewNote, setReviewNote] = useState("");
const [submitting, setSubmitting] = useState(false);
// Retrieve Zustand state
const {
baselineFeatureCollection,
selectedFeatureIds,
setSelectedFeatureIds,
timelineDraftYear,
setTimelineDraftYear,
backgroundVisibility,
geometryVisibility,
snapshotEntityRows,
snapshotWikis,
snapshotEntityWikiLinks,
leftPanelWidth,
setLeftPanelWidth,
rightPanelWidth,
setRightPanelWidth,
timelineFilterEnabled,
setTimelineFilterEnabled,
} = useEditorStore(useShallow((state) => ({
baselineFeatureCollection: state.baselineFeatureCollection,
selectedFeatureIds: state.selectedFeatureIds,
setSelectedFeatureIds: state.setSelectedFeatureIds,
timelineDraftYear: state.timelineDraftYear,
setTimelineDraftYear: state.setTimelineDraftYear,
backgroundVisibility: state.backgroundVisibility,
geometryVisibility: state.geometryVisibility,
snapshotEntityRows: state.snapshotEntityRows,
snapshotWikis: state.snapshotWikis,
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
leftPanelWidth: state.leftPanelWidth,
setLeftPanelWidth: state.setLeftPanelWidth,
rightPanelWidth: state.rightPanelWidth,
setRightPanelWidth: state.setRightPanelWidth,
timelineFilterEnabled: state.timelineFilterEnabled,
setTimelineFilterEnabled: state.setTimelineFilterEnabled,
})));
// Fetch submission details and project commit snapshot
useEffect(() => {
async function loadData() {
try {
setLoading(true);
setError(null);
const subRes = await apiGetSubmissionDetail(id);
if (!subRes || !subRes.status || !subRes.data) {
throw new Error("Không thể tải thông tin submission");
}
const subData = subRes.data as SubmissionItem;
setSubmission(subData);
setReviewNote(subData.review_note || "");
const projectId = subData.project_id;
const commits = await fetchProjectCommits(projectId);
const projRes = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
setProject(projRes);
const targetCommit = commits.find((c) => String(c.id) === String(subData.commit_id));
if (!targetCommit) {
throw new Error(`Không tìm thấy commit #${subData.commit_id} của project`);
}
const snapshot = normalizeEditorSnapshot(targetCommit.snapshot_json);
const session = snapshot ? toEditorSessionSnapshot(snapshot) : null;
setSessionSnapshot(session);
} catch (err: any) {
console.error("Error loading submission details:", err);
setError(err.message || "Lỗi hệ thống");
} finally {
setLoading(false);
}
}
if (id) {
loadData();
}
}, [id]);
// Apply filters based on timeline year
const activeTimelineYear = timelineDraftYear;
const activeTimelineFilterEnabled = timelineFilterEnabled;
const activeMapDraft = useMemo<FeatureCollection>(() => {
const draft = baselineFeatureCollection || EMPTY_FEATURE_COLLECTION;
if (!activeTimelineFilterEnabled) return draft;
return {
type: "FeatureCollection",
features: draft.features.filter((f) => isFeatureVisibleAtYear(f, activeTimelineYear)),
};
}, [baselineFeatureCollection, activeTimelineFilterEnabled, activeTimelineYear]);
const mapLabelContextDraft = useMemo<FeatureCollection>(() => {
return baselineFeatureCollection || EMPTY_FEATURE_COLLECTION;
}, [baselineFeatureCollection]);
// Handle review (approve / reject)
const handleReview = async (status: "APPROVED" | "REJECTED") => {
if (!submission) return;
setSubmitting(true);
try {
const res = await updateSubmission(submission.id, {
status,
review_note: reviewNote,
});
if (res?.status) {
toast.success("Cập nhật trạng thái submission thành công!");
setSubmission((prev) => prev ? { ...prev, status, review_note: reviewNote } : null);
} else {
toast.error(res?.message || "Cập nhật thất bại");
}
} catch (err: any) {
console.error(err);
toast.error("Lỗi khi cập nhật trạng thái submission");
} finally {
setSubmitting(false);
}
};
// Right Sidebar Geometry Choices
const geometryChoices = useMemo(() => {
const draft = baselineFeatureCollection || EMPTY_FEATURE_COLLECTION;
const mapRenderGeometryIds = new Set(
activeMapDraft.features.map((feature) => String(feature.properties.id))
);
const rows = draft.features
.filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number"))
.map((f) => {
const id = String(f.properties.id);
const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim();
const label = semantic.length ? `${semantic} (${f.geometry.type})` : "Geometry";
const timeStart = normalizeTimelineYearValue(f.properties.time_start);
const timeEnd = normalizeTimelineYearValue(f.properties.time_end);
const hasStart = timeStart !== null;
const hasEnd = timeEnd !== null;
const timeStatus: "missing" | "partial" | "complete" =
!hasStart && !hasEnd
? "missing"
: !hasStart || !hasEnd
? "partial"
: "complete";
const isTimelineVisible = mapRenderGeometryIds.has(id);
const timelineStatus: "off" | "visible" | "filteredOut" = !activeTimelineFilterEnabled
? "off"
: isTimelineVisible
? "visible"
: "filteredOut";
return {
id,
label,
time_start: timeStart,
time_end: timeEnd,
isTimelineVisible,
isOrphan: normalizeFeatureEntityIds(f).length === 0,
timeStatus,
timelineStatus,
isNew: false,
};
});
rows.sort((a, b) => {
const na = String(a.label || a.id);
const nb = String(b.label || b.id);
return na.localeCompare(nb);
});
return rows;
}, [baselineFeatureCollection, activeMapDraft, activeTimelineFilterEnabled]);
// Selected features state resolution
const selectedFeatures = useMemo<Feature[]>(() => {
const draft = baselineFeatureCollection || EMPTY_FEATURE_COLLECTION;
return selectedFeatureIds
.map((fid) => draft.features.find((f) => f.properties.id === fid) || null)
.filter((f): f is Feature => Boolean(f));
}, [baselineFeatureCollection, selectedFeatureIds]);
const selectedFeature = selectedFeatures[0] || null;
const selectedGeometryChildIds = useMemo<string[]>(() => {
if (!selectedFeature) return [];
return getDirectGeometryChildIds(
baselineFeatureCollection || EMPTY_FEATURE_COLLECTION,
String(selectedFeature.properties.id)
);
}, [baselineFeatureCollection, selectedFeature]);
const selectedGeometryTime = useMemo(() => {
if (!selectedFeature) return null;
const start = normalizeTimelineYearValue(selectedFeature.properties.time_start);
const end = normalizeTimelineYearValue(selectedFeature.properties.time_end);
return { time_start: start, time_end: end };
}, [selectedFeature]);
if (loading) {
return (
<div style={{ display: "flex", width: "100vw", height: "100vh", background: "#0b1220", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: "16px" }}>
<div style={{ width: "48px", height: "48px", border: "4px solid #1f2937", borderTop: "4px solid #38bdf8", borderRadius: "50%", animation: "spin 1s linear infinite" }} />
<div style={{ color: "#94a3b8", fontSize: "14px", fontWeight: 500 }}>Đang tải dữ liệu bản đ...</div>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
if (error || !submission || !project || !sessionSnapshot) {
return (
<div style={{ display: "flex", width: "100vw", height: "100vh", background: "#0b1220", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: "20px", color: "#f8fafc" }}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<div style={{ fontSize: "16px", fontWeight: 600 }}>{error || "Không thể tìm thấy submission"}</div>
<button
onClick={() => router.push("/submissions")}
style={{
background: "#3b82f6",
color: "white",
border: "none",
padding: "10px 20px",
borderRadius: "8px",
cursor: "pointer",
fontWeight: 600,
fontSize: "14px",
}}
>
Quay lại danh sách
</button>
</div>
);
}
return (
<div style={{ display: "flex", minHeight: "100vh", background: "#0b1220" }}>
<style>{`
html, body {
overflow: hidden !important;
scrollbar-width: none !important;
}
html::-webkit-scrollbar, body::-webkit-scrollbar {
display: none !important;
}
`}</style>
{/* Initialize store with project & snapshot data */}
<StoreInitializer project={project} sessionSnapshot={sessionSnapshot} />
{/* Left Sidebar showing submission details and review form */}
<div
style={{
width: leftPanelWidth,
background: "#0b1220",
borderRight: "1px solid #1f2937",
display: "flex",
flexDirection: "column",
height: "100vh",
color: "#f8fafc",
}}
>
{/* Header */}
<div style={{ padding: "16px", borderBottom: "1px solid #1f2937", display: "flex", alignItems: "center", gap: "12px" }}>
<button
onClick={() => router.push("/submissions")}
style={{
background: "transparent",
border: "none",
color: "#94a3b8",
cursor: "pointer",
padding: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
title="Quay lại danh sách"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<div>
<div style={{ fontSize: "11px", color: "#94a3b8", fontWeight: 500 }}>Chi tiết yêu cầu duyệt</div>
<div style={{ fontSize: "14px", fontWeight: 700, color: "#f8fafc" }}>#{submission.id.slice(0, 8)}</div>
</div>
</div>
{/* Info scroll area */}
<div style={{ flex: 1, overflowY: "auto", padding: "16px", display: "flex", flexDirection: "column", gap: "20px" }}>
{/* Project Title */}
<div>
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em", marginBottom: "6px" }}>Dự án</div>
<div style={{ fontSize: "15px", fontWeight: 700, color: "#38bdf8" }}>{submission.project_title}</div>
<div style={{ fontSize: "12px", color: "#cbd5e1", marginTop: "4px", lineHeight: "1.5" }}>{submission.project_description || "Không có mô tả dự án."}</div>
</div>
{/* Submitter */}
<div>
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em", marginBottom: "6px" }}>Người gửi</div>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
{submission.user?.avatar_url ? (
<img src={submission.user.avatar_url} alt="avatar" style={{ width: "32px", height: "32px", borderRadius: "50%" }} />
) : (
<div style={{ width: "32px", height: "32px", borderRadius: "50%", background: "#334155", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: "bold", fontSize: "12px", color: "#e2e8f0" }}>
{submission.user?.display_name?.charAt(0).toUpperCase() || "U"}
</div>
)}
<div>
<div style={{ fontSize: "13px", fontWeight: 600 }}>{submission.user?.display_name || "N/A"}</div>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{submission.user?.email || ""}</div>
</div>
</div>
</div>
{/* Meta grid */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>
<div>
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em", marginBottom: "4px" }}>Ngày tạo</div>
<div style={{ fontSize: "12px", color: "#e2e8f0" }}>{new Date(submission.created_at).toLocaleString("vi-VN")}</div>
</div>
<div>
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em", marginBottom: "4px" }}>Trạng thái</div>
<div>
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: "4px",
fontSize: "11px",
fontWeight: 700,
background: submission.status === "PENDING" ? "#eab308" : submission.status === "APPROVED" || submission.status === "SUCCESS" ? "#22c55e" : "#ef4444",
color: "#0f172a",
}}
>
{submission.status}
</span>
</div>
</div>
</div>
{submission.content && (
<div>
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em", marginBottom: "6px" }}>Nội dung ghi chú</div>
<div style={{ fontSize: "12px", color: "#cbd5e1", background: "#0f172a", padding: "10px", borderRadius: "6px", border: "1px solid #1f2937", whiteSpace: "pre-wrap" }}>
{submission.content}
</div>
</div>
)}
{/* Review Forms */}
<div style={{ borderTop: "1px solid #1f2937", paddingTop: "16px", marginTop: "8px" }}>
<div style={{ fontSize: "10px", textTransform: "uppercase", color: "#94a3b8", fontWeight: 600, letterSpacing: "0.05em", marginBottom: "10px" }}>Đánh giá của Admin</div>
{submission.status === "PENDING" ? (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<textarea
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
placeholder="Nhập phản hồi/ghi chú duyệt tại đây..."
style={{
width: "100%",
height: "100px",
background: "#0f172a",
border: "1px solid #1f2937",
borderRadius: "6px",
color: "#cbd5e1",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
resize: "none",
}}
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" }}>
<button
onClick={() => handleReview("APPROVED")}
disabled={submitting}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#16a34a",
color: "white",
border: "none",
padding: "10px 12px",
borderRadius: "6px",
cursor: submitting ? "not-allowed" : "pointer",
fontSize: "13px",
fontWeight: 700,
transition: "background 0.2s",
}}
>
{submitting ? "Đang xử lý..." : "Duyệt thông qua"}
</button>
<button
onClick={() => handleReview("REJECTED")}
disabled={submitting}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#dc2626",
color: "white",
border: "none",
padding: "10px 12px",
borderRadius: "6px",
cursor: submitting ? "not-allowed" : "pointer",
fontSize: "13px",
fontWeight: 700,
transition: "background 0.2s",
}}
>
{submitting ? "Đang xử lý..." : "Từ chối duyệt"}
</button>
</div>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<div>
<div style={{ fontSize: "11px", color: "#94a3b8" }}>Người duyệt</div>
<div style={{ fontSize: "13px", fontWeight: 600, color: "#e2e8f0" }}>{submission.reviewer?.display_name || "Admin"}</div>
</div>
{submission.reviewed_at && (
<div>
<div style={{ fontSize: "11px", color: "#94a3b8" }}>Thời gian duyệt</div>
<div style={{ fontSize: "12px", color: "#cbd5e1" }}>{new Date(submission.reviewed_at).toLocaleString("vi-VN")}</div>
</div>
)}
<div>
<div style={{ fontSize: "11px", color: "#94a3b8", marginBottom: "4px" }}>Ghi chú duyệt</div>
<div style={{ fontSize: "12px", color: "#cbd5e1", background: "#0f172a", padding: "10px", borderRadius: "6px", border: "1px solid #1f2937", minHeight: "40px" }}>
{submission.review_note || "(Không có ghi chú)"}
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Resize left panel */}
<ResizeHandle
title="Resize left panel"
onDrag={(deltaX) => {
setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520));
}}
/>
{/* Map Area */}
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
<Map
mode="idle"
renderDraft={activeMapDraft}
labelContextDraft={mapLabelContextDraft}
labelTimelineYear={activeTimelineFilterEnabled ? activeTimelineYear : null}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={setSelectedFeatureIds}
allowGeometryEditing={false}
allowFeatureSelection={true}
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
applyGeometryBindingFilter={activeTimelineFilterEnabled}
hoverPopupEnabled={true}
/>
<TimelineBar
year={activeTimelineYear}
onYearChange={setTimelineDraftYear}
isLoading={false}
disabled={false}
statusText={null}
filterEnabled={activeTimelineFilterEnabled}
onFilterEnabledChange={setTimelineFilterEnabled}
/>
</div>
{/* Resize right panel */}
<ResizeHandle
title="Resize right panel"
onDrag={(deltaX) => {
setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720));
}}
/>
{/* Right Sidebar showing geometry, entities, wikis, and bindings */}
<BackgroundLayersPanel
width={rightPanelWidth}
topContent={
<div style={{ display: "grid", gap: "12px" }}>
<GeometryBindingPanel
geometries={geometryChoices}
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
selectedGeometryChildIds={selectedGeometryChildIds}
onToggleBindGeometryForSelectedGeometry={() => {}} // no-op read-only
onFocusGeometry={(id) => {
const target = baselineFeatureCollection?.features.find((f) => String(f.properties.id) === String(id));
if (target) {
setSelectedFeatureIds([target.properties.id]);
}
}}
/>
<ProjectEntityRefsPanel
onCreateEntityOnly={() => {}}
onUpdateEntity={() => {}}
hasSelectedGeometry={Boolean(selectedFeature)}
selectedGeometryTime={selectedGeometryTime}
onToggleBindEntityForSelectedGeometry={() => {}}
onRerollEntityId={() => {}}
onDeleteEntity={() => {}}
/>
<WikiSidebarPanel
projectId={project.id}
setWikis={() => {}}
onRemoveWiki={() => {}}
/>
<EntityWikiBindingsPanel
setLinks={() => {}}
/>
{selectedFeatures.length > 0 ? (
<SelectedGeometryPanel
selectedFeatures={selectedFeatures}
onApplyGeometryMetadata={async () => ({ ok: true })}
onDeleteFeatures={() => {}}
onDeselectAll={() => setSelectedFeatureIds([])}
changeCount={0}
onReplayEdit={() => {}}
onRerollGeometryId={() => {}}
/>
) : null}
</div>
}
/>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
export type StoredTokens = {
access_token: string;
};
const LS_KEY = "uhm_auth_tokens_v1";
let cached: StoredTokens | null = null;
function safeParseTokens(raw: string | null): StoredTokens | null {
if (!raw) return null;
try {
const v = JSON.parse(raw) as Partial<StoredTokens>;
if (!v || typeof v !== "object") return null;
if (typeof v.access_token !== "string") return null;
if (!v.access_token.trim()) return null;
return { access_token: v.access_token };
} catch {
return null;
}
}
export function getStoredTokens(): StoredTokens | null {
if (cached) return cached;
if (typeof window === "undefined") return null;
cached = safeParseTokens(window.localStorage.getItem(LS_KEY));
return cached;
}
export function setStoredTokens(tokens: StoredTokens | null): void {
cached = tokens;
if (typeof window === "undefined") return;
if (!tokens) {
window.localStorage.removeItem(LS_KEY);
return;
}
window.localStorage.setItem(LS_KEY, JSON.stringify(tokens));
}
export function getAccessToken(): string | null {
return getStoredTokens()?.access_token ?? null;
}
export function clearStoredTokens(): void {
setStoredTokens(null);
}
// Helper for dealing with CommonResponse where token payload shape is not strictly typed.
export function extractTokensFromResponsePayload(payload: any): StoredTokens | null {
const data = payload?.data ?? payload;
const tokenContainer = data?.tokens ?? data?.token_set ?? data;
const access =
tokenContainer?.access_token ??
tokenContainer?.accessToken ??
tokenContainer?.token ??
tokenContainer?.access ??
tokenContainer?.jwt ??
null;
if (typeof access === "string" && access.trim()) {
return { access_token: access };
}
return null;
}
+2 -2
View File
@@ -292,10 +292,10 @@ export default function SubmissionsTable({
<div className="w-[100px] shrink-0 flex items-center justify-end">
<button
onClick={() => onActionClick(item)}
onClick={() => onViewDetails(item.id)}
className="px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50 transition-colors"
>
Đánh giá
Chi tiết
</button>
</div>
</div>
+112 -46
View File
@@ -1,26 +1,34 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import axios, { AxiosResponse } from "axios";
import { API_URL_ROOT } from "../../api";
import {
clearStoredTokens,
extractTokensFromResponsePayload,
getAccessToken,
setStoredTokens,
} from "@/auth/tokenStore";
// Prefer same-origin requests. Next.js will proxy API paths via `next.config.ts` rewrites.
export const baseURL = "/";
export const baseURL = API_URL_ROOT;
export const api = axios.create({
baseURL,
// Support both cookie-based auth (httpOnly) and Bearer JWT.
withCredentials: true,
});
// Dedicated instance for refresh to avoid interceptor loops and handle baseURL correctly.
const refreshApi = axios.create({
baseURL,
withCredentials: true,
});
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
interface QueueItem {
resolve: () => void;
reject: (error: unknown) => void;
}
let isRefreshing = false;
let queue: QueueItem[] = [];
let queue: any[] = [];
const processQueue = (error: unknown = null) => {
function shouldRedirectToSigninOnRefreshFailure(status?: number): boolean {
return status === 401 || status === 404;
}
const processQueue = (error?: any) => {
queue.forEach((p) => {
if (error) p.reject(error);
else p.resolve();
@@ -28,38 +36,83 @@ const processQueue = (error: unknown = null) => {
queue = [];
};
const skipRefreshUrls = [
"/auth/signin",
"/auth/signup",
"/auth/logout",
"/auth/refresh",
"/auth/forgot-password",
"/auth/token/create",
"/auth/token/verify",
];
api.interceptors.request.use((config: any) => {
if (config.skipAuth) return config;
const token = config.authToken || getAccessToken();
if (token) {
const headers: any = config.headers || {};
// If it's a retry after refresh, we MUST update the Authorization header with the fresh token.
// Otherwise, we only set it if not already present.
const hasAuth = !!(headers.Authorization || headers.authorization || (typeof headers.get === "function" && headers.get("Authorization")));
if (config._retry || !hasAuth) {
if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`);
else headers.Authorization = `Bearer ${token}`;
}
config.headers = headers;
}
return config;
});
function isAuthTokenExpiredMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
if (!normalized) return false;
// Be specific: don't match general "unauthorized" or "access denied" which could be 403.
// Match only messages clearly indicating token expiration or invalidity.
return (
normalized.includes("invalid or expired jwt") ||
normalized.includes("jwt expired") ||
normalized.includes("token expired") ||
normalized.includes("invalid token") ||
normalized.includes("expired token") ||
normalized.includes("token is invalid") ||
normalized.includes("not authenticated")
);
}
api.interceptors.response.use(
(res) => res,
async (err: AxiosError) => {
const originalRequest = err.config as CustomAxiosRequestConfig;
async (res: AxiosResponse): Promise<AxiosResponse> => {
// Opportunistically persist tokens from signin/refresh responses.
const tokens = extractTokensFromResponsePayload(res?.data);
if (tokens) setStoredTokens(tokens);
const url = originalRequest?.url || "";
const shouldSkip = skipRefreshUrls?.some((path) =>
url?.includes(path)
);
// Handle backends that return 200 OK with status:false + expired token message.
const data = res.data;
const originalRequest = res.config as any;
const url = String(originalRequest?.url || "");
if (
err.response?.status === 401 &&
originalRequest &&
data &&
data.status === false &&
isAuthTokenExpiredMessage(data.message || "") &&
!originalRequest._retry &&
!shouldSkip
!originalRequest.skipRefresh &&
!url.includes("/auth/")
) {
return performRefreshAndRetry(originalRequest);
}
return res;
},
async (err) => {
const originalRequest = err.config as any;
const url = String(originalRequest?.url || "");
if (err.response?.status === 401 && !originalRequest._retry && !originalRequest.skipRefresh && !url.includes("/auth/")) {
return performRefreshAndRetry(originalRequest);
}
return Promise.reject(err);
}
);
async function performRefreshAndRetry(originalRequest: any): Promise<AxiosResponse> {
if (isRefreshing) {
return new Promise((resolve, reject) => {
queue.push({
resolve: () => resolve(api(originalRequest)),
reject: (queueErr) => reject(queueErr),
reject,
});
});
}
@@ -68,22 +121,35 @@ api.interceptors.response.use(
isRefreshing = true;
try {
await axios.post("/auth/refresh", {}, { withCredentials: true });
const tryCookieRefresh = async () => {
return refreshApi.post("/auth/refresh", {});
};
processQueue(null);
const refreshRes: any = await tryCookieRefresh();
const nextTokens = extractTokensFromResponsePayload(refreshRes?.data);
if (nextTokens) setStoredTokens(nextTokens);
// Some backends may return only a new access token.
else {
const maybeAccess = (refreshRes?.data?.data?.access_token ?? refreshRes?.data?.access_token) as unknown;
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
setStoredTokens({ access_token: maybeAccess });
}
}
processQueue();
return api(originalRequest);
} catch (refreshErr) {
} catch (refreshErr: any) {
processQueue(refreshErr);
window.location.href = "/auth/signin"
// Treat missing/invalid refresh endpoints or sessions as logged-out state.
if (shouldRedirectToSigninOnRefreshFailure(refreshErr?.response?.status)) {
clearStoredTokens();
if (typeof window !== "undefined") {
window.location.href = "/auth/signin";
}
}
return Promise.reject(refreshErr);
} finally {
isRefreshing = false;
}
}
return Promise.reject(err);
}
);
}
+9
View File
@@ -0,0 +1,9 @@
export interface ChatbotPayload {
project_id?: string;
question: string;
}
export interface ChatbotResponse {
status: boolean;
data: string;
}
+339
View File
@@ -0,0 +1,339 @@
.container {
position: absolute;
left: 18px;
right: 18px;
bottom: 16px;
z-index: 10;
background: linear-gradient(135deg, rgba(30, 30, 30, 0.72) 0%, rgba(20, 20, 20, 0.85) 100%);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50px;
padding: 10px 16px;
color: #f8fafc;
box-shadow:
0 10px 30px -10px rgba(0, 0, 0, 0.5),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.05);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes borderPulse {
0% {
border-color: rgba(255, 255, 255, 0.6);
box-shadow:
0 10px 30px -10px rgba(0, 0, 0, 0.15),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
}
50% {
border-color: rgba(16, 185, 129, 0.55);
box-shadow:
0 10px 30px -10px rgba(0, 0, 0, 0.15),
0 0 12px rgba(16, 185, 129, 0.25),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.75),
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
}
100% {
border-color: rgba(255, 255, 255, 0.6);
box-shadow:
0 10px 30px -10px rgba(0, 0, 0, 0.15),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
}
}
.containerLoading {
animation: borderPulse 2s infinite ease-in-out;
}
.flexWrapper {
display: flex;
align-items: center;
flex-wrap: wrap;
row-gap: 8px;
column-gap: 12px;
font-size: 12px;
}
.labelBounds {
color: #94a3b8;
font-weight: 600;
min-width: 44px;
user-select: none;
}
.labelBoundsRight {
composes: labelBounds;
text-align: right;
}
/* Custom range slider styling */
.slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
min-width: 120px;
height: 24px;
background: transparent;
cursor: pointer;
outline: none;
}
.slider::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
.slider:hover::-webkit-slider-runnable-track {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.1);
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -6px;
height: 18px;
width: 18px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%);
border: 1.5px solid #ffffff;
box-shadow:
0 0 10px rgba(16, 185, 129, 0.4),
0 3px 6px rgba(0, 0, 0, 0.15),
inset 0 1px 1px rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease;
}
.slider:hover::-webkit-slider-thumb {
transform: scale(1.2);
box-shadow:
0 0 15px rgba(16, 185, 129, 0.6),
0 5px 10px rgba(0, 0, 0, 0.18),
inset 0 1px 1px rgba(255, 255, 255, 0.5);
}
.slider:active::-webkit-slider-thumb {
transform: scale(1.05);
box-shadow:
0 0 8px rgba(16, 185, 129, 0.5),
0 2px 4px rgba(0, 0, 0, 0.15);
}
/* Firefox slider styling */
.slider::-moz-range-track {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
.slider:hover::-moz-range-track {
background: rgba(255, 255, 255, 0.25);
}
.slider::-moz-range-thumb {
height: 15px;
width: 15px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%);
border: 1.5px solid #ffffff;
box-shadow:
0 0 10px rgba(16, 185, 129, 0.4),
0 3px 6px rgba(0, 0, 0, 0.15),
inset 0 1px 1px rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease;
}
.slider:hover::-moz-range-thumb {
transform: scale(1.2);
}
/* Custom inputs styling */
.numberInput {
width: 128px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-size: 13px;
font-weight: 600;
outline: none;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.numberInput:hover {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.12);
}
.numberInput:focus {
border-color: #10b981;
box-shadow:
0 0 0 3px rgba(16, 185, 129, 0.25),
inset 0 1px 2px rgba(0, 0, 0, 0.2);
background: rgba(255, 255, 255, 0.15);
}
.rangeInput {
composes: numberInput;
width: 84px;
}
.rangeLabel {
display: inline-flex;
align-items: center;
gap: 8px;
color: #94a3b8;
font-weight: 600;
white-space: nowrap;
transition: color 0.2s;
}
.rangeLabel:hover {
color: #ffffff;
}
/* Custom switch toggle styling */
.toggleContainer {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
}
.toggleContainer:focus-visible {
outline: 2px solid rgba(52, 211, 153, 0.8);
outline-offset: 3px;
border-radius: 999px;
}
.toggleTrack {
width: 38px;
height: 20px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
position: relative;
flex: 0 0 auto;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggleTrackActive {
background: rgba(16, 185, 129, 0.35);
border-color: rgba(16, 185, 129, 0.6);
box-shadow:
0 0 8px rgba(16, 185, 129, 0.35),
inset 0 1px 2px rgba(0, 0, 0, 0.2);
}
.toggleThumb {
position: absolute;
top: 1.5px;
left: 2px;
width: 15px;
height: 15px;
border-radius: 50%;
background: #94a3b8;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggleThumbActive {
left: 19px;
background: #34d399;
box-shadow:
0 0 10px rgba(52, 211, 153, 0.6),
0 2px 4px rgba(0, 0, 0, 0.25);
}
.toggleContainer:hover .toggleTrack {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.15);
}
.toggleContainer:hover .toggleTrackActive {
border-color: rgba(16, 185, 129, 0.7);
}
.disabled {
opacity: 0.5 !important;
cursor: not-allowed !important;
pointer-events: none !important;
}
.disabled .slider,
.disabled .numberInput,
.disabled .toggleContainer,
.disabled .toggleTrack {
cursor: not-allowed !important;
pointer-events: none !important;
}
.numberWrapper {
display: flex;
align-items: center;
gap: 6px;
}
.adjustGroup {
display: flex;
align-items: center;
gap: 4px;
}
.adjustBtn {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
cursor: pointer;
font-size: 14px;
font-weight: 600;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
outline: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.adjustBtn:hover {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.15);
}
.adjustBtn:active {
background: rgba(16, 185, 129, 0.25);
border-color: #10b981;
}
.adjustBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+34
View File
@@ -0,0 +1,34 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
import { setStoredTokens, clearStoredTokens } from "@/auth/tokenStore";
export type AuthTokens = {
access_token: string;
};
export type CurrentUser = {
id: string;
email?: string;
display_name?: string;
avatar_url?: string | null;
roles?: string[];
};
export async function signIn(email: string, password: string): Promise<AuthTokens> {
const res = await requestJson<AuthTokens>(
API_ENDPOINTS.authSignin,
jsonRequestInit("POST", { email, password }),
{ skipAuth: true }
);
if (res?.access_token) setStoredTokens(res);
return res;
}
export async function logout(): Promise<void> {
await requestJson(API_ENDPOINTS.authLogout, { method: "POST" });
clearStoredTokens();
}
export async function fetchCurrentUser(): Promise<CurrentUser> {
return requestJson<CurrentUser>(API_ENDPOINTS.currentUser);
}
+61
View File
@@ -0,0 +1,61 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { BattleReplay } from "@/uhm/types/projects";
const BATCH_SIZE = 10;
const BATCH_CONCURRENCY = 4;
export async function fetchBattleReplaysByGeometryIds(geometryIds: string[]): Promise<Record<string, BattleReplay[]>> {
const uniqueIds = Array.from(new Set(
(geometryIds || [])
.map((id) => String(id || "").trim())
.filter((id) => id.length > 0)
));
if (!uniqueIds.length) {
return {};
}
const chunks: string[][] = [];
for (let index = 0; index < uniqueIds.length; index += BATCH_SIZE) {
chunks.push(uniqueIds.slice(index, index + BATCH_SIZE));
}
const results: Array<Record<string, BattleReplay[]>> = new Array(chunks.length);
const runnerCount = Math.max(1, Math.min(BATCH_CONCURRENCY, chunks.length));
let nextIndex = 0;
await Promise.all(
Array.from({ length: runnerCount }, async () => {
while (true) {
const current = nextIndex++;
if (current >= chunks.length) return;
const batch = chunks[current];
const params = new URLSearchParams();
for (const id of batch) {
params.append("geometry_ids", id);
}
try {
results[current] = await requestJson<Record<string, BattleReplay[]>>(
`${API_ENDPOINTS.battleReplays}/geometries?${params.toString()}`
);
} catch (err) {
console.error("Failed to fetch battle replays batch", err);
results[current] = {};
}
}
})
);
const merged: Record<string, BattleReplay[]> = {};
for (const res of results) {
if (!res) continue;
for (const [key, list] of Object.entries(res)) {
merged[key] = list || [];
}
}
return merged;
}
+58
View File
@@ -0,0 +1,58 @@
import { API_URL_ROOT } from "../../../api";
const GOONG_TILES_BASE_URL = "https://tiles.goong.io";
export const API_BASE_URL = normalizeApiBaseUrl(API_URL_ROOT);
export const GOONG_SATELLITE_STYLE_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/assets/goong_satellite.json`;
export const GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/assets/goong_map_web.json`;
export const GOONG_GLYPHS_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/fonts/{fontstack}/{range}.pbf`;
export const USE_EXTERNAL_BACKGROUND_RASTER = API_BASE_URL.length > 0;
function normalizeApiBaseUrl(rawUrl: string): string {
return rawUrl.trim().replace(/\/+$/, "");
}
export function stripGoongApiKeyFromUrl(rawUrl: string): string {
const [basePart, hashPart = ""] = rawUrl.split("#", 2);
const [pathPart, queryString = ""] = basePart.split("?", 2);
const sanitizedQuery = queryString
.split("&")
.filter((segment) => segment && !segment.toLowerCase().startsWith("api_key="))
.join("&");
return `${pathPart}${sanitizedQuery ? `?${sanitizedQuery}` : ""}${hashPart ? `#${hashPart}` : ""}`;
}
export function buildGoongProxyUrl(rawUrl: string): string {
const sanitizedUrl = stripGoongApiKeyFromUrl(rawUrl);
const proxyTarget = sanitizedUrl
.trim()
.replace(/^https?:\/\//i, "")
.replace(/^\/+/, "");
const isMapProxy = !proxyTarget.startsWith("rsapi.goong.io");
const proxyPrefix = isMapProxy ? `${API_BASE_URL}/map/proxy` : `${API_BASE_URL}/api/proxy`;
return `${proxyPrefix}/${proxyTarget}`;
}
export const GOONG_GLYPHS_PROXY_URL = buildGoongProxyUrl(GOONG_GLYPHS_UPSTREAM_URL);
export const API_ENDPOINTS = {
geometries: `${API_BASE_URL}/geometries`,
entities: `${API_BASE_URL}/entities`,
wikis: `${API_BASE_URL}/wikis`,
relations: `${API_BASE_URL}/relations`,
wikiContent: (id: string) => `${API_BASE_URL}/wikis/content/${id}`,
// New API uses projects + commits + submissions (JWT-protected).
authSignin: `${API_BASE_URL}/auth/signin`,
authRefresh: `${API_BASE_URL}/auth/refresh`,
authLogout: `${API_BASE_URL}/auth/logout`,
currentUser: `${API_BASE_URL}/users/current`,
currentUserProjects: `${API_BASE_URL}/users/current/project`,
projects: `${API_BASE_URL}/projects`,
submissions: `${API_BASE_URL}/submissions`,
battleReplays: `${API_BASE_URL}/battle-replays`,
} as const;
+50
View File
@@ -0,0 +1,50 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { Entity } from "@/uhm/types/entities";
export type { Entity } from "@/uhm/types/entities";
export async function fetchEntities(query?: {
q?: string;
limit?: number;
cursor?: string;
projectId?: string;
}): Promise<Entity[]> {
const params = new URLSearchParams();
// API mới dùng `name` thay vì `q`.
if (query && "q" in query) {
params.set("name", String(query.q ?? ""));
}
if (query?.limit && Number.isFinite(query.limit)) {
params.set("limit", String(Math.trunc(query.limit)));
}
if (query?.cursor) {
params.set("cursor", query.cursor);
}
if (query?.projectId) {
params.set("project_id", query.projectId);
}
const suffix = params.toString();
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
return requestJson<Entity[]>(url);
}
export async function searchEntitiesByName(
name: string,
options?: { limit?: number }
): Promise<Entity[]> {
const keyword = name.trim();
if (!keyword.length) return [];
const params = new URLSearchParams({ name: keyword });
if (options?.limit && Number.isFinite(options.limit)) {
params.set("limit", String(Math.trunc(options.limit)));
}
// API mới không có `/entities/search`, search qua query string.
return requestJson<Entity[]>(`${API_ENDPOINTS.entities}?${params.toString()}`);
}
export async function fetchEntityById(id: string): Promise<Entity> {
return requestJson<Entity>(`${API_ENDPOINTS.entities}/${id}`);
}
+317
View File
@@ -0,0 +1,317 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { GeometriesBBoxQuery } from "@/uhm/types/api";
import type { Feature, FeatureCollection, FeatureEntityPreview, FeatureProperties, FeatureWikiPreview, Geometry } from "@/uhm/types/geo";
import { geoTypeCodeToTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
export type { GeometriesBBoxQuery } from "@/uhm/types/api";
export type EntityGeometrySearchGeo = {
id: string;
type: string | null;
draw_geometry: Geometry;
bound_with?: string | null;
time_start?: number | null;
time_end?: number | null;
};
export type EntityGeometriesSearchItem = {
entity_id: string;
name: string;
description: string;
geometries: EntityGeometrySearchGeo[];
};
export type SearchGeometriesByEntityNameResponse = {
items: EntityGeometriesSearchItem[];
next_cursor?: string;
};
type EntityGeometrySearchGeoRow = Omit<EntityGeometrySearchGeo, "type"> & {
geo_type: number;
};
type EntityGeometriesSearchItemRow = Omit<EntityGeometriesSearchItem, "geometries"> & {
geometries: EntityGeometrySearchGeoRow[];
};
type SearchGeometriesByEntityNameApiResponse = Omit<SearchGeometriesByEntityNameResponse, "items"> & {
items: EntityGeometriesSearchItemRow[];
};
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
const query = new URLSearchParams({
// API mới dùng snake_case
min_lng: String(params.minLng),
min_lat: String(params.minLat),
max_lng: String(params.maxLng),
max_lat: String(params.maxLat),
});
if (params.time !== undefined) {
query.set("time", String(params.time));
}
if (params.timeRange !== undefined) {
query.set("time_range", String(params.timeRange));
}
if (params.entity_id) {
query.set("entity_id", params.entity_id);
}
if (typeof params.hasBound === "boolean") {
query.set("has_bound", String(params.hasBound));
}
return query.toString();
}
export async function fetchGeometriesByBBox(params: GeometriesBBoxQuery): Promise<FeatureCollection> {
const url = `${API_ENDPOINTS.geometries}?${buildBBoxQueryString(params)}`;
// API mới trả về list geometries, FE cần chuyển thành GeoJSON FeatureCollection.
const rows = await requestJson<GeometryRow[]>(url);
return geometriesToFeatureCollection(rows);
}
export async function fetchGeometriesByBoundWith(parentGeometryId: string): Promise<FeatureCollection> {
const id = String(parentGeometryId || "").trim();
if (!id) return { type: "FeatureCollection", features: [] };
const rows = await requestJson<GeometryRow[]>(
`${API_ENDPOINTS.geometries}/bound-with/${encodeURIComponent(id)}`
);
return geometriesToFeatureCollection(rows);
}
export async function searchGeometriesByEntityName(
name: string,
options?: { cursor?: string; limit?: number }
): Promise<SearchGeometriesByEntityNameResponse> {
const keyword = name.trim();
if (!keyword.length) return { items: [] };
const params = new URLSearchParams({ name: keyword });
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit && Number.isFinite(options.limit)) {
params.set("limit", String(Math.trunc(options.limit)));
}
const response = await requestJson<SearchGeometriesByEntityNameApiResponse>(
`${API_ENDPOINTS.geometries}/entity?${params.toString()}`
);
return {
...response,
items: normalizeEntityGeometryItems(response.items),
};
}
export async function fetchEntityGeometryIndexPage(options?: {
cursor?: string;
limit?: number;
}): Promise<SearchGeometriesByEntityNameResponse> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit && Number.isFinite(options.limit)) {
params.set("limit", String(Math.trunc(options.limit)));
}
const suffix = params.toString();
const response = await requestJson<SearchGeometriesByEntityNameApiResponse>(
`${API_ENDPOINTS.geometries}/entity${suffix ? `?${suffix}` : ""}`
);
return {
...response,
items: normalizeEntityGeometryItems(response.items),
};
}
type GeometryRow = {
id: string;
geo_type: number;
draw_geometry: Geometry;
bound_with?: string | null;
time_start?: number;
time_end?: number;
bbox?: {
min_lng: number;
min_lat: number;
max_lng: number;
max_lat: number;
} | null;
replay_ids?: string[] | null;
entity_id?: string | null;
entity_name?: string | null;
entity_description?: string | null;
entities?: GeometryRowEntity[];
};
type GeometryRowEntity = {
id?: string | null;
entity_id?: string | null;
name?: string | null;
entity_name?: string | null;
description?: string | null;
entity_description?: string | null;
time_start?: number | null;
time_end?: number | null;
wikis?: GeometryRowWiki[];
};
type GeometryRowWiki = {
id?: string | null;
wiki_id?: string | null;
title?: string | null;
slug?: string | null;
preview_quote?: string | null;
blockquote_preview?: string | null;
content?: string | null;
};
function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
const features: Feature[] = [];
for (const row of rows || []) {
const geometry = normalizeGeometry(row.draw_geometry);
if (!geometry) continue;
const boundWith = normalizeBoundWith(row.bound_with);
const typeKey = geoTypeCodeToTypeKey(row.geo_type) || null;
const entityPreviews = normalizeGeometryRowEntities(row);
const entityIds = entityPreviews.map((entity) => entity.id);
const entityNames = entityPreviews.map((entity) => entity.name);
const properties: FeatureProperties = {
id: row.id,
type: typeKey,
time_start: row.time_start ?? null,
time_end: row.time_end ?? null,
replay_ids: normalizeStringArray(row.replay_ids),
bound_with: boundWith,
...(entityPreviews.length
? {
entity_id: entityIds[0] || null,
entity_ids: entityIds,
entity_name: entityNames[0] || null,
entity_names: entityNames,
entity_label_candidates: entityPreviews.map((entity) => ({
id: entity.id,
name: entity.name,
time_start: entity.time_start ?? null,
time_end: entity.time_end ?? null,
})),
public_entity_previews: entityPreviews,
}
: {}),
};
features.push({
type: "Feature",
properties,
geometry,
});
}
return { type: "FeatureCollection", features };
}
function normalizeGeometryRowEntities(row: GeometryRow): FeatureEntityPreview[] {
const candidates: GeometryRowEntity[] = Array.isArray(row.entities) ? row.entities : [];
if (!candidates.length && (row.entity_id || row.entity_name)) {
candidates.push({
entity_id: row.entity_id,
entity_name: row.entity_name,
entity_description: row.entity_description,
});
}
const byId = new Map<string, FeatureEntityPreview>();
for (const candidate of candidates) {
const id = normalizeString(candidate.id ?? candidate.entity_id);
if (!id) continue;
const name = normalizeString(candidate.name ?? candidate.entity_name) || id;
byId.set(id, {
id,
name,
description: normalizeNullableString(candidate.description ?? candidate.entity_description),
time_start: normalizeNumber(candidate.time_start),
time_end: normalizeNumber(candidate.time_end),
wikis: normalizeGeometryRowWikis(candidate.wikis),
});
}
return Array.from(byId.values());
}
function normalizeGeometryRowWikis(wikis: GeometryRowWiki[] | undefined): FeatureWikiPreview[] {
if (!Array.isArray(wikis)) return [];
const byId = new Map<string, FeatureWikiPreview>();
for (const wiki of wikis) {
const id = normalizeString(wiki.id ?? wiki.wiki_id);
if (!id) continue;
byId.set(id, {
id,
title: normalizeNullableString(wiki.title) ?? undefined,
slug: normalizeNullableString(wiki.slug),
preview_quote: normalizeNullableString(wiki.preview_quote ?? wiki.blockquote_preview),
content: normalizeNullableString(wiki.content),
});
}
return Array.from(byId.values());
}
function normalizeString(value: unknown): string {
if (typeof value !== "string" && typeof value !== "number") return "";
return String(value).trim();
}
function normalizeNullableString(value: unknown): string | null {
const normalized = normalizeString(value);
return normalized.length ? normalized : null;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => normalizeString(item))
.filter((item) => item.length > 0);
}
function normalizeNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function normalizeGeometry(value: unknown): Geometry | null {
if (!value || typeof value !== "object") return null;
const g = value as Record<string, unknown>;
if (typeof g.type !== "string") return null;
if (!("coordinates" in g)) return null;
return value as Geometry;
}
function normalizeBoundWith(value: unknown): string | null {
if (value == null) return null;
if (typeof value !== "string" && typeof value !== "number") return null;
const id = String(value).trim();
return id.length ? id : null;
}
function normalizeEntityGeometryItems(items: EntityGeometriesSearchItemRow[] | undefined): EntityGeometriesSearchItem[] {
return (items || []).map((item) => ({
...item,
geometries: (item.geometries || []).map((geometry) => ({
id: geometry.id,
type: geoTypeCodeToTypeKey(geometry.geo_type) || null,
draw_geometry: geometry.draw_geometry,
bound_with: normalizeBoundWith(geometry.bound_with),
time_start: geometry.time_start ?? null,
time_end: geometry.time_end ?? null,
})),
}));
}
+188
View File
@@ -0,0 +1,188 @@
import { buildGoongProxyUrl } from "@/uhm/api/config";
const GOONG_PLACE_API_URL = "https://rsapi.goong.io/Place";
const GOONG_GEOCODE_API_URL = "https://rsapi.goong.io/Geocode";
export type PresentPlacePrediction = {
placeId: string;
description: string;
mainText: string;
secondaryText: string;
};
export type PresentPlaceSelection = {
placeId: string;
name: string;
address: string;
lat: number;
lng: number;
};
export type ReverseGeocodePlace = {
label: string;
address: string;
lat: number;
lng: number;
};
export function hasSearchMapApiKey(): boolean {
return true;
}
export async function searchPresentPlaces(
input: string,
signal?: AbortSignal
): Promise<PresentPlacePrediction[]> {
const keyword = input.trim();
if (keyword.length < 2) return [];
const proxyBase = buildGoongProxyUrl(`${GOONG_PLACE_API_URL}/AutoComplete`);
const url = `${proxyBase}?input=${encodeURIComponent(keyword)}&limit=8`;
const payload = await fetchGoongJson(url, signal);
const predictions = Array.isArray(payload.predictions) ? payload.predictions : [];
return predictions
.map(normalizePrediction)
.filter((prediction): prediction is PresentPlacePrediction => Boolean(prediction));
}
export async function fetchPresentPlaceDetail(
placeId: string,
signal?: AbortSignal
): Promise<PresentPlaceSelection> {
const id = placeId.trim();
if (!id) {
throw new Error("Thiếu place_id.");
}
const proxyBase = buildGoongProxyUrl(`${GOONG_PLACE_API_URL}/Detail`);
const url = `${proxyBase}?place_id=${encodeURIComponent(id)}`;
const payload = await fetchGoongJson(url, signal);
const result = isRecord(payload.result) ? payload.result : null;
const location = isRecord(result?.geometry) && isRecord(result.geometry.location)
? result.geometry.location
: null;
const lat = toFiniteNumber(location?.lat);
const lng = toFiniteNumber(location?.lng);
if (lat === null || lng === null) {
throw new Error("Không tìm thấy tọa độ địa điểm.");
}
const name = normalizeText(result?.name) || normalizeText(result?.formatted_address) || id;
const address = normalizeText(result?.formatted_address) || normalizeText(result?.address) || name;
return {
placeId: id,
name,
address,
lat,
lng,
};
}
export async function reverseGeocodePresentPlace(
lng: number,
lat: number,
signal?: AbortSignal
): Promise<ReverseGeocodePlace> {
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
throw new Error("Tọa độ reverse geocode không hợp lệ.");
}
const proxyBase = buildGoongProxyUrl(GOONG_GEOCODE_API_URL);
const url = `${proxyBase}?latlng=${lat},${lng}`;
const payload = await fetchGoongJson(url, signal);
const results = Array.isArray(payload.results) ? payload.results : [];
const firstResult = results.find((item) => isRecord(item)) as Record<string, unknown> | undefined;
if (!firstResult) {
throw new Error("Không tìm thấy địa chỉ gần tọa độ này.");
}
const address = normalizeText(firstResult.formatted_address) ||
normalizeText(firstResult.description) ||
normalizeText(firstResult.name);
const label = buildReverseGeocodeLabel(firstResult) || address;
if (!label && !address) {
throw new Error("Goong không trả về địa chỉ hợp lệ.");
}
return {
label: label || address,
address: address || label,
lat,
lng,
};
}
async function fetchGoongJson(url: string | URL, signal?: AbortSignal): Promise<Record<string, unknown>> {
const response = await fetch(url.toString(), { signal });
if (!response.ok) {
throw new Error(`Goong request failed (${response.status}).`);
}
const payload = await response.json() as unknown;
if (!isRecord(payload)) {
throw new Error("Goong response không hợp lệ.");
}
const status = normalizeText(payload.status).toUpperCase();
if (status && status !== "OK") {
const message = normalizeText(payload.error_message) || normalizeText(payload.message) || "Goong request failed.";
throw new Error(message);
}
return payload;
}
function normalizePrediction(input: unknown): PresentPlacePrediction | null {
if (!isRecord(input)) return null;
const placeId = normalizeText(input.place_id);
const description = normalizeText(input.description);
if (!placeId || !description) return null;
const structured = isRecord(input.structured_formatting) ? input.structured_formatting : null;
const mainText = normalizeText(structured?.main_text) || description;
const secondaryText = normalizeText(structured?.secondary_text);
return {
placeId,
description,
mainText,
secondaryText,
};
}
function buildReverseGeocodeLabel(result: Record<string, unknown>): string {
const compound = isRecord(result.compound) ? result.compound : null;
if (compound) {
const parts = [
normalizeText(compound.commune),
normalizeText(compound.district),
normalizeText(compound.province),
].filter((part) => part.length > 0);
if (parts.length) {
return Array.from(new Set(parts)).join(", ");
}
}
return normalizeText(result.formatted_address) ||
normalizeText(result.description) ||
normalizeText(result.name);
}
function normalizeText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function toFiniteNumber(value: unknown): number | null {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
+140
View File
@@ -0,0 +1,140 @@
import type { ApiEnvelope } from "@/uhm/types/api";
import { api } from "@/config/config";
import type { AxiosError, AxiosRequestConfig } from "axios";
export class ApiError extends Error {
status: number;
body: string;
errors: unknown[];
constructor(message: string, status: number, body: string, errors: unknown[] = []) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
this.errors = errors;
}
}
type RequestJsonOptions = {
skipAuth?: boolean;
skipRefresh?: boolean;
authToken?: string | null; // Override bearer token (used for refresh).
};
type AuthAxiosRequestConfig = AxiosRequestConfig & RequestJsonOptions;
export async function requestJson<T>(
input: RequestInfo | URL,
init?: RequestInit,
options?: RequestJsonOptions
): Promise<T> {
const url = typeof input === "string" ? input : String(input);
const method = init?.method || "GET";
// Convert RequestInit.body to object if it's a JSON string.
let data = init?.body;
if (typeof data === "string" && data.length > 0) {
try {
data = JSON.parse(data);
} catch {
// Keep as string if not JSON.
}
}
try {
const requestConfig: AuthAxiosRequestConfig = {
url,
method,
data,
headers: normalizeRequestHeaders(init?.headers),
// Custom properties for our axios interceptor.
skipAuth: options?.skipAuth,
authToken: options?.authToken,
skipRefresh: options?.skipRefresh,
};
const response = await api.request(requestConfig);
const payload = response.data;
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
if (envelope) {
const isError = envelope.status === false || envelope.status === "error";
if (isError) {
const message = extractErrorMessage(payload, envelope) || "Request failed";
throw new ApiError(message, response.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
}
return (envelope.data ?? null) as T;
}
return payload as T;
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
const axiosError = err as AxiosError<unknown>;
const status = axiosError.response?.status || 0;
const payload = axiosError.response?.data;
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
const message = extractErrorMessage(payload, envelope) || (err instanceof Error ? err.message : "") || "Request failed";
const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload);
const errors = envelope?.errors ? normalizeErrors(envelope.errors) : [];
throw new ApiError(message, status, body, errors);
}
}
export function jsonRequestInit(method: string, body: unknown): RequestInit {
return {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
};
}
function isApiEnvelopeLike<T>(value: unknown): value is ApiEnvelope<T> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const source = value as Record<string, unknown>;
return "status" in source && ("data" in source || "message" in source || "errors" in source);
}
function normalizeErrors(value: unknown): unknown[] {
if (value == null) return [];
if (Array.isArray(value)) return value;
return [value];
}
function extractErrorMessage(payload: unknown, envelope: ApiEnvelope<unknown> | null): string | null {
const payloadRecord = isRecord(payload) ? payload : null;
const msg =
(typeof envelope?.message === "string" && envelope.message.trim()) ||
(typeof payloadRecord?.message === "string" && payloadRecord.message.trim());
if (msg) return msg;
const errors = envelope?.errors ?? payloadRecord?.errors;
if (typeof errors === "string" && errors.trim()) return errors.trim();
if (Array.isArray(errors) && typeof errors[0] === "string") return errors[0];
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeRequestHeaders(headers: RequestInit["headers"]): Record<string, string> | undefined {
if (!headers) return undefined;
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers.map(([key, value]) => [key, value]));
}
return headers;
}
function stringifyPayload(payload: unknown): string {
if (typeof payload === "string") return payload;
try {
return JSON.stringify(payload);
} catch {
return String(payload);
}
}
+155
View File
@@ -0,0 +1,155 @@
import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config";
import { ApiError, jsonRequestInit, requestJson } from "@/uhm/api/http";
import { toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type {
CreateCommitInput,
CreateProjectInput,
EditorLoadResponse,
RestoreCommitInput,
Project,
ProjectCommit,
ProjectState,
ProjectSubmission,
} from "@/uhm/types/projects";
export type {
CreateCommitInput,
CreateProjectInput,
EditorLoadResponse,
RestoreCommitInput,
Project,
ProjectCommit,
ProjectState,
ProjectSubmission,
} from "@/uhm/types/projects";
// Projects (API cũ) => Projects (API mới)
export async function fetchProjects(): Promise<Project[]> {
// /users/current/project requires JWT.
return requestJson<Project[]>(API_ENDPOINTS.currentUserProjects);
}
export async function createProject(input: CreateProjectInput): Promise<Project> {
// POST /projects
return requestJson<Project>(API_ENDPOINTS.projects, jsonRequestInit("POST", input));
}
export async function openSectionEditor(projectId: string): Promise<EditorLoadResponse> {
// API mới không có endpoint "editor". FE tự load:
// 1) Project details
// 2) Project commits (to get snapshot_json of latest commit)
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
const pending = (project.submissions || []).find((s) => s?.status === "PENDING") || null;
if (pending) {
// BE rule: pending submission blocks further editing/submitting until deleted/reviewed.
// We surface a typed error so UI can offer "delete to unlock".
throw new ApiError(
"Project has a pending submission",
409,
JSON.stringify({ pending_submission_id: pending.id })
);
}
const commits = await fetchProjectCommits(projectId);
const headCommitId = project.latest_commit_id ?? null;
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
const snapshot = headCommit?.snapshot_json ?? null;
const state: ProjectState = {
status: project.project_status || "ACTIVE",
head_commit_id: headCommitId,
locked_by: project.locked_by ?? null,
};
return {
project: project,
state,
commit: headCommit,
snapshot,
};
}
export async function createProjectCommit(
projectId: string,
input: CreateCommitInput
): Promise<{ commit: ProjectCommit; state: ProjectState }> {
// POST /projects/{id}/commits
const snapshot = toApiEditorSnapshot(input.snapshot);
const commit = await requestJson<ProjectCommit>(
`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits`,
jsonRequestInit("POST", {
snapshot_json: snapshot,
edit_summary: input.edit_summary,
})
);
// Refresh project state (latest_commit_id may have moved).
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
const state: ProjectState = {
status: project.project_status || "ACTIVE",
head_commit_id: project.latest_commit_id ?? null,
locked_by: project.locked_by ?? null,
};
return { commit, state };
}
export async function fetchProjectCommits(projectId: string): Promise<ProjectCommit[]> {
return requestJson<ProjectCommit[]>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits`);
}
export async function restoreProjectCommit(
projectId: string,
input: RestoreCommitInput
): Promise<{ commit: ProjectCommit | null; state: ProjectState }> {
// POST /projects/{id}/commits/restore
await requestJson(
`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits/restore`,
jsonRequestInit("POST", { commit_id: input.commit_id })
);
// Reload commits + project to determine new head commit.
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
const commits = await fetchProjectCommits(projectId);
const headCommitId = project.latest_commit_id ?? null;
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
const state: ProjectState = {
status: project.project_status || "ACTIVE",
head_commit_id: headCommitId,
locked_by: project.locked_by ?? null,
};
return { commit: headCommit, state };
}
export async function submitSection(projectId: string, content: string): Promise<ProjectSubmission> {
// Submit latest commit of project
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
const commitId = project.latest_commit_id;
if (!commitId) {
throw new Error("Project has no latest commit to submit");
}
return requestJson<ProjectSubmission>(
API_ENDPOINTS.submissions,
jsonRequestInit("POST", {
project_id: projectId,
commit_id: commitId,
content: content,
})
);
}
export async function deleteSubmission(submissionId: string): Promise<unknown> {
return requestJson(
`${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}`,
{ method: "DELETE" }
);
}
// Convenience for runtime logs/debug: expose effective base.
export const EFFECTIVE_API_BASE_URL = API_BASE_URL;
+231
View File
@@ -0,0 +1,231 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
import type { Geometry } from "@/uhm/types/geo";
const RELATION_BATCH_SIZE = 10;
const RELATION_BATCH_CONCURRENCY = 4;
export type WikiContentPreview = {
id: string;
preview?: string | null;
created_at?: string | null;
};
export type RelationGeometry = {
id: string;
draw_geometry: Geometry;
type?: string | null;
geo_type?: number | null;
bound_with?: string | null;
time_start?: number | null;
time_end?: number | null;
};
type RelationType =
| "wiki-entity"
| "entity-wiki"
| "geometry-entity"
| "entity-geometry"
| "entity-geometry-child"
| "entity-geometry-alone";
const entitiesPromiseCache: Record<string, Promise<Entity[]>> = {};
export async function fetchRelationMap<T>(type: RelationType, ids: string[]): Promise<Record<string, T[]>> {
const uniqueIds = uniqueStrings(ids);
if (!uniqueIds.length) return {};
return requestJson<Record<string, T[]>>(
`${API_ENDPOINTS.relations}?type=${encodeURIComponent(type)}&${buildArrayQuery("ids", uniqueIds)}`
);
}
export async function fetchEntitiesByWikiIds(ids: string[]): Promise<Record<string, Entity[]>> {
return fetchRelationMap<Entity>("wiki-entity", ids);
}
export async function fetchGeometriesByEntityIds(ids: string[]): Promise<Record<string, RelationGeometry[]>> {
return fetchRelationMap<RelationGeometry>("entity-geometry", ids);
}
export async function fetchEntitiesByGeometryIds(ids: string[]): Promise<Record<string, Entity[]>> {
const uniqueIds = uniqueStrings(ids);
const missingIds = uniqueIds.filter(id => !entitiesPromiseCache[id]);
if (missingIds.length > 0) {
const batchPromise = (async () => {
const result: Record<string, Entity[]> = {};
const pages = await mapWithConcurrency(
chunkIds(missingIds),
RELATION_BATCH_CONCURRENCY,
(batch) => requestJson<Record<string, Entity[]>>(
`${API_ENDPOINTS.relations}/entities-by-geometries?${buildArrayQuery("geometry_ids", batch)}`
)
);
for (const rows of pages) {
mergeRelationRecord(result, rows);
}
return result;
})();
batchPromise.catch(() => {});
for (const id of missingIds) {
entitiesPromiseCache[id] = batchPromise
.then(res => res[id] || [])
.catch(err => {
// Xóa khỏi cache để lần sau thử lại
delete entitiesPromiseCache[id];
// Trả về [] để không làm sập Promise.all của UI
return [];
});
}
}
const result: Record<string, Entity[]> = {};
await Promise.all(uniqueIds.map(async id => {
result[id] = await entitiesPromiseCache[id];
}));
return result;
}
export async function fetchWikisByEntityIds(ids: string[]): Promise<Record<string, Wiki[]>> {
const result: Record<string, Wiki[]> = {};
const pages = await mapWithConcurrency(
chunkIds(ids),
RELATION_BATCH_CONCURRENCY,
(batch) => requestJson<Record<string, Wiki[]>>(
`${API_ENDPOINTS.relations}/wikis-by-entities?${buildArrayQuery("entity_ids", batch)}`
)
);
for (const rows of pages) {
mergeRelationRecord(result, rows);
}
return result;
}
export async function fetchWikiContentPreviewsByIds(ids: string[]): Promise<WikiContentPreview[]> {
const result: WikiContentPreview[] = [];
const seen = new Set<string>();
const pages = await mapWithConcurrency(
chunkIds(ids),
RELATION_BATCH_CONCURRENCY,
(batch) => requestJson<WikiContentPreview[]>(
`${API_ENDPOINTS.relations}/wiki-contents/preview?${buildArrayQuery("ids", batch)}`
)
);
for (const rows of pages) {
for (const row of rows || []) {
const id = String(row?.id || "").trim();
if (!id || seen.has(id)) continue;
seen.add(id);
result.push(row);
}
}
return result;
}
const wikisWithPreviewPromiseCache: Record<string, Promise<Wiki[]>> = {};
export async function fetchWikisByEntityIdsWithPreviews(ids: string[]): Promise<Record<string, Wiki[]>> {
const uniqueIds = uniqueStrings(ids);
const missingIds = uniqueIds.filter(id => !wikisWithPreviewPromiseCache[id]);
if (missingIds.length > 0) {
const batchPromise = (async () => {
const wikisByEntityId = await fetchWikisByEntityIds(missingIds);
const previewContentIds = uniqueStrings(
Object.values(wikisByEntityId || {})
.flat()
.map((wiki) => wiki.content_sample?.[0]?.id)
);
if (!previewContentIds.length) return wikisByEntityId;
const previews = await fetchWikiContentPreviewsByIds(previewContentIds);
const previewById = new Map(
previews.map((item) => [String(item.id), String(item.preview || "").trim()])
);
const result: Record<string, Wiki[]> = {};
for (const [entityId, wikis] of Object.entries(wikisByEntityId || {})) {
result[entityId] = (wikis || []).map((wiki) => {
const previewId = wiki.content_sample?.[0]?.id;
const preview = previewId ? previewById.get(String(previewId)) || "" : "";
return preview ? { ...wiki, preview_quote: preview } : wiki;
});
}
return result;
})();
batchPromise.catch(() => {});
for (const id of missingIds) {
wikisWithPreviewPromiseCache[id] = batchPromise
.then(res => res[id] || [])
.catch(err => {
// Xóa khỏi cache để lần sau thử lại
delete wikisWithPreviewPromiseCache[id];
// Trả về [] để không làm sập Promise.all của UI
return [];
});
}
}
const result: Record<string, Wiki[]> = {};
await Promise.all(uniqueIds.map(async id => {
result[id] = await wikisWithPreviewPromiseCache[id];
}));
return result;
}
function buildArrayQuery(key: string, values: string[]): string {
const query = new URLSearchParams();
for (const value of uniqueStrings(values)) {
query.append(key, value);
}
return query.toString();
}
function chunkIds(ids: string[]): string[][] {
const values = uniqueStrings(ids);
const chunks: string[][] = [];
for (let index = 0; index < values.length; index += RELATION_BATCH_SIZE) {
chunks.push(values.slice(index, index + RELATION_BATCH_SIZE));
}
return chunks;
}
function uniqueStrings(values: Array<string | null | undefined>): string[] {
return Array.from(new Set(
values
.map((value) => String(value || "").trim())
.filter((value) => value.length > 0)
));
}
function mergeRelationRecord<T>(target: Record<string, T[]>, source: Record<string, T[]> | undefined) {
for (const [key, rows] of Object.entries(source || {})) {
target[key] = rows || [];
}
}
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length);
const runnerCount = Math.max(1, Math.min(Math.trunc(concurrency), items.length));
let nextIndex = 0;
await Promise.all(
Array.from({ length: runnerCount }, async () => {
while (true) {
const current = nextIndex++;
if (current >= items.length) return;
results[current] = await worker(items[current]);
}
})
);
return results;
}
+584
View File
@@ -0,0 +1,584 @@
import {
buildGoongProxyUrl,
GOONG_SATELLITE_STYLE_UPSTREAM_URL,
GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL,
USE_EXTERNAL_BACKGROUND_RASTER,
} from "@/uhm/api/config";
import { GOONG_LABEL_FALLBACK_FONT_STACK } from "@/uhm/lib/map/styles/shared/textFonts";
import maplibregl from "maplibre-gl";
export type GoongBackgroundGroupId =
| "bg-country-borders-line"
| "bg-province-borders-line"
| "bg-district-borders-line"
| "country-labels"
| "rivers-line";
type GoongStyleSource = {
type?: string;
url?: string;
tiles?: string[];
tileSize?: number;
attribution?: string;
bounds?: number[];
scheme?: "xyz" | "tms";
minzoom?: number;
maxzoom?: number;
};
type GoongSourceManifest = {
tiles?: string[];
tileSize?: number;
pixel_scale?: number | string;
attribution?: string;
bounds?: number[];
scheme?: "xyz" | "tms";
minzoom?: number;
maxzoom?: number;
};
type GoongStyleDocument = {
glyphs?: string;
sprite?: string;
sources?: Record<string, GoongStyleSource>;
layers?: maplibregl.LayerSpecification[];
};
let externalRasterSourcePromise: Promise<maplibregl.RasterSourceSpecification> | null = null;
let goongOverlayBundlePromise: Promise<GoongBackgroundOverlayBundle | null> | null = null;
const goongStyleDocumentPromises = new Map<string, Promise<GoongStyleDocument>>();
const goongSourceSpecificationPromises = new Map<string, Promise<maplibregl.SourceSpecification>>();
type GoongBackgroundOverlayBundle = {
sources: Record<string, maplibregl.SourceSpecification>;
layers: maplibregl.LayerSpecification[];
};
export async function getBackgroundRasterSourceSpecification(): Promise<maplibregl.RasterSourceSpecification> {
if (!USE_EXTERNAL_BACKGROUND_RASTER) {
throw new Error("NEXT_PUBLIC_API_URL_ROOT is not configured.");
}
if (!externalRasterSourcePromise) {
externalRasterSourcePromise = loadGoongRasterSourceSpecification(
GOONG_SATELLITE_STYLE_UPSTREAM_URL
);
}
try {
return await externalRasterSourcePromise;
} catch (error) {
externalRasterSourcePromise = null;
throw error;
}
}
export async function getGoongBackgroundOverlayBundle(): Promise<GoongBackgroundOverlayBundle | null> {
if (!USE_EXTERNAL_BACKGROUND_RASTER) {
throw new Error("NEXT_PUBLIC_API_URL_ROOT is not configured.");
}
if (!goongOverlayBundlePromise) {
goongOverlayBundlePromise = loadGoongBackgroundOverlayBundle(
GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL
);
}
try {
return await goongOverlayBundlePromise;
} catch (error) {
goongOverlayBundlePromise = null;
throw error;
}
}
async function loadGoongRasterSourceSpecification(
styleUpstreamUrl: string
): Promise<maplibregl.RasterSourceSpecification> {
const style = await loadGoongStyleDocument(styleUpstreamUrl);
const sources = style.sources || {};
for (const source of Object.values(sources)) {
if (source.type !== "raster") continue;
const spec = await normalizeGoongSourceSpecification(source, styleUpstreamUrl);
if (spec.type === "raster" && (spec.tiles?.length || "url" in spec)) {
return spec;
}
}
throw new Error("No raster source found in Goong satellite style.");
}
async function loadGoongBackgroundOverlayBundle(
styleUpstreamUrl: string
): Promise<GoongBackgroundOverlayBundle | null> {
const style = await loadGoongStyleDocument(styleUpstreamUrl);
const layers = style.layers || [];
const sources = style.sources || {};
const layerById = new Map(layers.map((layer) => [layer.id, layer]));
const selectedLayersByGroup = new Map<GoongBackgroundGroupId, maplibregl.LayerSpecification[]>([
["bg-country-borders-line", []],
["bg-province-borders-line", []],
["bg-district-borders-line", []],
["rivers-line", []],
["country-labels", []],
]);
for (const rawLayer of layers) {
const resolvedLayer = resolveLayerReference(rawLayer, layerById);
const groupId = detectGoongBackgroundGroup(resolvedLayer);
if (!groupId) continue;
selectedLayersByGroup.get(groupId)?.push(resolvedLayer);
}
const selectedSourceIds = new Set<string>();
for (const groupLayers of selectedLayersByGroup.values()) {
for (const layer of groupLayers) {
if ("source" in layer && typeof layer.source === "string") {
selectedSourceIds.add(layer.source);
}
}
}
if (selectedSourceIds.size === 0) {
return null;
}
const sourceIdMap = new Map<string, string>();
const overlaySources: Record<string, maplibregl.SourceSpecification> = {};
const overlaySourceEntries = await Promise.all(
[...selectedSourceIds].map(async (sourceId) => {
const source = sources[sourceId];
if (!source) {
return null;
}
const prefixedId = `goong-overlay-${sourceId}`;
const normalizedSource = await normalizeGoongSourceSpecification(
source,
styleUpstreamUrl
);
return { sourceId, prefixedId, normalizedSource };
})
);
for (const entry of overlaySourceEntries) {
if (!entry) continue;
sourceIdMap.set(entry.sourceId, entry.prefixedId);
overlaySources[entry.prefixedId] = entry.normalizedSource;
}
const overlayLayers: maplibregl.LayerSpecification[] = [];
for (const groupId of [
"rivers-line",
"bg-country-borders-line",
"bg-province-borders-line",
"bg-district-borders-line",
"country-labels",
] as const) {
const groupLayers = [...(selectedLayersByGroup.get(groupId) || [])].sort(compareOverlayLayers);
groupLayers.forEach((layer, index) => {
overlayLayers.push(
cloneOverlayLayer(layer, {
id: `goong-${groupId}-${index}`,
groupId,
sourceIdMap,
})
);
});
}
return {
sources: overlaySources,
layers: overlayLayers,
};
}
async function loadGoongStyleDocument(styleUpstreamUrl: string): Promise<GoongStyleDocument> {
const existingPromise = goongStyleDocumentPromises.get(styleUpstreamUrl);
if (existingPromise) {
return existingPromise;
}
const styleProxyUrl = buildGoongProxyUrl(styleUpstreamUrl);
const promise = fetch(styleProxyUrl, { cache: "force-cache" })
.then(async (response) => {
if (!response.ok) {
throw new Error(`Goong style request failed with status ${response.status}`);
}
return (await response.json()) as GoongStyleDocument;
});
goongStyleDocumentPromises.set(styleUpstreamUrl, promise);
try {
return await promise;
} catch (error) {
goongStyleDocumentPromises.delete(styleUpstreamUrl);
throw error;
}
}
async function loadGoongSourceSpecification(
sourceUpstreamUrl: string,
parentSource: GoongStyleSource
): Promise<maplibregl.SourceSpecification> {
const cacheKey = JSON.stringify({
sourceUpstreamUrl,
type: parentSource.type,
tileSize: parentSource.tileSize,
minzoom: parentSource.minzoom,
maxzoom: parentSource.maxzoom,
});
const existingPromise = goongSourceSpecificationPromises.get(cacheKey);
if (existingPromise) {
return existingPromise;
}
const sourceProxyUrl = buildGoongProxyUrl(sourceUpstreamUrl);
const promise = fetch(sourceProxyUrl, { cache: "force-cache" })
.then(async (response) => {
if (!response.ok) {
throw new Error(`Goong source request failed with status ${response.status}`);
}
return (await response.json()) as GoongSourceManifest;
})
.then((sourceDocument) =>
normalizeManifestBackedGoongSourceSpecification(parentSource, sourceDocument, sourceUpstreamUrl)
);
goongSourceSpecificationPromises.set(cacheKey, promise);
try {
return await promise;
} catch (error) {
goongSourceSpecificationPromises.delete(cacheKey);
throw error;
}
}
async function normalizeGoongSourceSpecification(
source: GoongStyleSource,
parentDocumentUrl: string
): Promise<maplibregl.SourceSpecification> {
if (typeof source.url === "string" && source.url) {
const sourceUpstreamUrl = resolveGoongResourceUrl(source.url, parentDocumentUrl);
return loadGoongSourceSpecification(sourceUpstreamUrl, source);
}
return normalizeInlineGoongSourceSpecification(source, parentDocumentUrl);
}
function normalizeInlineGoongSourceSpecification(
source: GoongStyleSource,
parentDocumentUrl: string
): maplibregl.SourceSpecification {
return buildMapLibreSourceSpecification(source, parentDocumentUrl);
}
function normalizeManifestBackedGoongSourceSpecification(
parentSource: GoongStyleSource,
sourceManifest: GoongSourceManifest,
sourceUpstreamUrl: string
): maplibregl.SourceSpecification {
const mergedSource: GoongStyleSource = {
...parentSource,
attribution: sourceManifest.attribution ?? parentSource.attribution,
bounds: sourceManifest.bounds ?? parentSource.bounds,
maxzoom: sourceManifest.maxzoom ?? parentSource.maxzoom,
minzoom: sourceManifest.minzoom ?? parentSource.minzoom,
scheme: sourceManifest.scheme ?? parentSource.scheme,
tileSize:
sourceManifest.tileSize ??
normalizeGoongTileSize(sourceManifest.pixel_scale) ??
parentSource.tileSize,
tiles: sourceManifest.tiles ?? parentSource.tiles,
};
return buildMapLibreSourceSpecification(mergedSource, sourceUpstreamUrl);
}
function buildMapLibreSourceSpecification(
source: GoongStyleSource,
parentDocumentUrl: string
): maplibregl.SourceSpecification {
const resolvedTiles = Array.isArray(source.tiles)
? source.tiles.map((tileUrl) => {
const upstreamTileUrl = resolveGoongResourceUrl(tileUrl, parentDocumentUrl);
return buildGoongProxyUrl(upstreamTileUrl);
})
: undefined;
if (source.type === "raster") {
const rasterSource: maplibregl.RasterSourceSpecification = {
type: "raster",
...(resolvedTiles?.length ? { tiles: resolvedTiles } : {}),
...(typeof source.tileSize === "number" ? { tileSize: source.tileSize } : {}),
...(typeof source.minzoom === "number" ? { minzoom: source.minzoom } : {}),
...(typeof source.maxzoom === "number" ? { maxzoom: source.maxzoom } : {}),
...(Array.isArray(source.bounds) ? { bounds: source.bounds as [number, number, number, number] } : {}),
...(source.scheme ? { scheme: source.scheme } : {}),
...(source.attribution ? { attribution: source.attribution } : {}),
};
return rasterSource;
}
if (source.type === "vector") {
const vectorSource: maplibregl.VectorSourceSpecification = {
type: "vector",
...(resolvedTiles?.length ? { tiles: resolvedTiles } : {}),
...(typeof source.minzoom === "number" ? { minzoom: source.minzoom } : {}),
...(typeof source.maxzoom === "number" ? { maxzoom: source.maxzoom } : {}),
...(Array.isArray(source.bounds) ? { bounds: source.bounds as [number, number, number, number] } : {}),
...(source.scheme ? { scheme: source.scheme } : {}),
...(source.attribution ? { attribution: source.attribution } : {}),
};
return vectorSource;
}
throw new Error(`Unsupported Goong source type: ${String(source.type || "unknown")}`);
}
function normalizeGoongTileSize(value: number | string | undefined): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsedValue = Number.parseInt(value, 10);
if (Number.isFinite(parsedValue)) {
return parsedValue;
}
}
return undefined;
}
function resolveLayerReference(
layer: maplibregl.LayerSpecification,
layerById: Map<string, maplibregl.LayerSpecification>
): maplibregl.LayerSpecification {
const withRef = layer as maplibregl.LayerSpecification & { ref?: string };
if (!withRef.ref) {
return deepClone(layer);
}
const parent = layerById.get(withRef.ref);
if (!parent) {
return deepClone(layer);
}
const resolvedParent = resolveLayerReference(parent, layerById);
const merged = {
...resolvedParent,
...deepClone(layer),
} as maplibregl.LayerSpecification & {
ref?: string;
layout?: Record<string, unknown>;
paint?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
merged.layout = {
...(resolvedParent as { layout?: Record<string, unknown> }).layout,
...(withRef as { layout?: Record<string, unknown> }).layout,
};
merged.paint = {
...(resolvedParent as { paint?: Record<string, unknown> }).paint,
...(withRef as { paint?: Record<string, unknown> }).paint,
};
merged.metadata = {
...(resolvedParent as { metadata?: Record<string, unknown> }).metadata,
...(withRef as { metadata?: Record<string, unknown> }).metadata,
};
delete merged.ref;
return merged;
}
function detectGoongBackgroundGroup(
layer: maplibregl.LayerSpecification
): GoongBackgroundGroupId | null {
const haystack = [
layer.id,
"source" in layer && typeof layer.source === "string" ? layer.source : "",
"source-layer" in layer && typeof layer["source-layer"] === "string" ? layer["source-layer"] : "",
]
.join(" ")
.toLowerCase();
if (layer.type === "symbol" && hasTextField(layer) && isPreferredPlaceLabelLayer(haystack)) {
return "country-labels";
}
if (layer.type === "line") {
const boundaryGroup = detectBoundaryGroup(layer, haystack);
if (boundaryGroup) {
return boundaryGroup;
}
}
if (layer.type === "line" && /(water|waterway|river|stream|canal)/.test(haystack)) {
return "rivers-line";
}
if (layer.type === "fill" && /(water|lake|reservoir|sea|ocean)/.test(haystack)) {
return "rivers-line";
}
return null;
}
function hasTextField(layer: maplibregl.LayerSpecification): boolean {
const layout = (layer as { layout?: Record<string, unknown> }).layout;
return Boolean(layout && "text-field" in layout && layout["text-field"]);
}
function isPreferredPlaceLabelLayer(haystack: string): boolean {
if (/(poi|airport|station|transit|rail|metro|bus|road|street|highway|path|route)/.test(haystack)) {
return false;
}
return /(country|state|province|district|admin|place|city|town|village|settlement|capital|label)/.test(haystack);
}
function detectBoundaryGroup(
_layer: maplibregl.LayerSpecification,
haystack: string
): GoongBackgroundGroupId | null {
if (/(road|street|highway|path|route|rail|transit|water|waterway|river|stream|canal)/.test(haystack)) {
return null;
}
if (!/(boundary|border|admin|country|state|province|district|ward|commune|county)/.test(haystack)) {
return null;
}
// Goong's public styles expose the boundary hierarchy most clearly
// through boundary-land-type-{0,1,2}. Prefer these exact matches over
// keyword heuristics because the heuristic buckets were mixing levels.
if (/boundary-land-type-0/.test(haystack)) {
if (/boundary-land-type-0-bg/.test(haystack)) {
return null;
}
return "bg-country-borders-line";
}
if (/boundary-land-type-1/.test(haystack)) {
if (/boundary-land-type-1-bg/.test(haystack)) {
return null;
}
return "bg-province-borders-line";
}
if (/boundary-land-type-2/.test(haystack)) {
return "bg-district-borders-line";
}
const adminLevels = extractAdminLevels(haystack);
if (adminLevels.length > 0) {
const minAdminLevel = Math.min(...adminLevels);
if (minAdminLevel <= 2) return "bg-country-borders-line";
if (minAdminLevel <= 5) return "bg-province-borders-line";
return "bg-district-borders-line";
}
if (/(district|ward|commune|subdistrict|neighbou?rhood)/.test(haystack)) {
return "bg-district-borders-line";
}
if (/(province|state|region)/.test(haystack)) {
return "bg-province-borders-line";
}
if (/(country|national|international)/.test(haystack)) {
return "bg-country-borders-line";
}
return null;
}
function extractAdminLevels(haystack: string): number[] {
const matches = Array.from(
haystack.matchAll(/(?:admin[_ -]?level|adminlevel|admin|level)[_ -]?(\d{1,2})/g)
);
return matches
.map((match) => Number.parseInt(match[1] || "", 10))
.filter((value) => Number.isFinite(value));
}
function cloneOverlayLayer(
layer: maplibregl.LayerSpecification,
options: {
id: string;
groupId: GoongBackgroundGroupId;
sourceIdMap: Map<string, string>;
}
): maplibregl.LayerSpecification {
const cloned = deepClone(layer) as maplibregl.LayerSpecification & {
source?: string;
layout?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
cloned.id = options.id;
if (typeof cloned.source === "string" && options.sourceIdMap.has(cloned.source)) {
cloned.source = options.sourceIdMap.get(cloned.source);
}
cloned.metadata = {
...(cloned.metadata || {}),
uhmBackgroundGroupId: options.groupId,
uhmBackgroundProvider: "goong",
};
if (options.groupId === "country-labels") {
const layout = { ...(cloned.layout || {}) };
delete layout["icon-image"];
delete layout["icon-size"];
delete layout["icon-allow-overlap"];
delete layout["icon-ignore-placement"];
if (!Array.isArray(layout["text-font"])) {
layout["text-font"] = [...GOONG_LABEL_FALLBACK_FONT_STACK];
}
cloned.layout = layout;
}
return cloned;
}
function compareOverlayLayers(
left: maplibregl.LayerSpecification,
right: maplibregl.LayerSpecification
): number {
const leftMinzoom = "minzoom" in left && typeof left.minzoom === "number"
? left.minzoom
: -1;
const rightMinzoom = "minzoom" in right && typeof right.minzoom === "number"
? right.minzoom
: -1;
if (leftMinzoom !== rightMinzoom) {
return leftMinzoom - rightMinzoom;
}
return left.id.localeCompare(right.id);
}
function resolveGoongResourceUrl(value: string, parentDocumentUrl: string): string {
if (/^[a-z]+:\/\//i.test(value) || value.startsWith("data:")) {
return value;
}
try {
return new URL(value, parentDocumentUrl).toString();
} catch {
return value;
}
}
function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
+75
View File
@@ -0,0 +1,75 @@
import { api } from "@/config/config";
import { API_ENDPOINTS } from "@/uhm/api/config";
import { ApiError, requestJson } from "@/uhm/api/http";
export type Wiki = {
id: string;
project_id: string;
title?: string;
slug?: string | null;
content?: string;
preview_quote?: string | null;
is_deleted?: boolean;
created_at?: string;
updated_at?: string;
content_sample?: {
id: string;
title: string;
created_at: string;
}[];
};
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
const keyword = title.trim();
const params = new URLSearchParams({ title: keyword });
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.entityId) params.set("entity_id", options.entityId);
return requestJson<Wiki[]>(`${API_ENDPOINTS.wikis}?${params.toString()}`);
}
export async function fetchWikiById(id: string): Promise<Wiki> {
const wikiId = String(id || "").trim();
if (!wikiId) throw new Error("Missing wiki id");
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
}
export async function fetchWikiBySlug(slug: string): Promise<Wiki | null> {
const value = String(slug || "").trim();
if (!value.length) return null;
try {
return await requestJson<Wiki>(`${API_ENDPOINTS.wikis}/slug/${encodeURIComponent(value)}`);
} catch (err) {
// Treat "not found" as an empty result for search UX.
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
export async function checkWikiSlugExists(slug: string): Promise<boolean> {
const value = String(slug || "").trim();
if (!value.length) return false;
const params = new URLSearchParams({ slug: value });
const url = `${API_ENDPOINTS.wikis}/slug/exists?${params.toString()}`;
const payload = await requestJson<unknown>(url);
if (typeof payload === "boolean") return payload;
if (payload && typeof payload === "object") {
const source = payload as Record<string, unknown>;
if (typeof source.exists === "boolean") return source.exists;
if (typeof source.exists === "number") return source.exists !== 0;
if (typeof source.is_exists === "boolean") return source.is_exists;
if (typeof source.is_exists === "number") return source.is_exists !== 0;
}
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
return true;
}
export const getContentByVersionWikiId = async (id: string) => {
const response = await api.get(API_ENDPOINTS.wikiContent(id));
return response?.data;
};
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { memo, useState } from "react";
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { ProjectPanel } from "./editor/ProjectPanel";
import { ToolsPanel } from "./editor/ToolsPanel";
import { CommitPanel } from "./editor/CommitPanel";
import { CommitHistoryPanel } from "./editor/CommitHistoryPanel";
import { UndoListPanel } from "./editor/UndoListPanel";
import { SubmitModal } from "./editor/SubmitModal";
import ImageOverlayPanel from "./editor/ImageOverlayPanel";
import type { MapImageOverlay } from "@/uhm/components/map/imageOverlay";
type Props = {
mode: EditorMode;
setMode: (mode: EditorMode) => void;
entityStatus?: string | null;
onUndo: () => void;
onCommit: () => void;
onSubmit: (content: string) => void;
onRestoreCommit: (commitId: string) => void;
isSaving: boolean;
isSubmitting: boolean;
sectionTitle: string;
projectStatus: string;
commitTitle: string;
onCommitTitleChange: (title: string) => void;
commitCount: number;
hasHeadCommit: boolean;
headCommitId: string | null;
latestCommitLabel: string | null;
commits: Array<{
id: string;
created_at?: string;
edit_summary: string;
user_id: string;
}>;
changesCount: number;
undoStack: UndoAction[];
width?: number;
imageOverlay: MapImageOverlay | null;
onPickImageOverlay: (file: File | null) => void;
onPasteImageOverlay: () => void;
imageOverlayKeyboardEnabled: boolean;
onImageOverlayKeyboardEnabledChange: (enabled: boolean) => void;
onImageOverlayOpacityChange: (opacity: number) => void;
onRemoveImageOverlay: () => void;
};
function Editor({
mode,
setMode,
entityStatus,
onUndo,
onCommit,
onSubmit,
onRestoreCommit,
isSaving,
isSubmitting,
sectionTitle,
projectStatus,
commitTitle,
onCommitTitleChange,
commitCount,
hasHeadCommit,
headCommitId,
latestCommitLabel,
commits,
changesCount,
undoStack,
width = 350,
imageOverlay,
onPickImageOverlay,
onPasteImageOverlay,
imageOverlayKeyboardEnabled,
onImageOverlayKeyboardEnabledChange,
onImageOverlayOpacityChange,
onRemoveImageOverlay,
}: Props) {
// State đóng/mở modal submit project.
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
// State nội dung submit gửi lên backend khi user xác nhận.
const [submitContent, setSubmitContent] = useState("");
// Mở modal submit với nội dung sạch cho lần submit mới.
const handleOpenSubmitModal = () => {
setSubmitContent("");
setIsSubmitModalOpen(true);
};
// Xác nhận submit: đóng modal trước rồi chuyển content cho command cha.
const handleConfirmSubmit = () => {
setIsSubmitModalOpen(false);
onSubmit(submitContent);
};
// Hủy submit mà không thay đổi draft/commit.
const handleCancelSubmit = () => {
setIsSubmitModalOpen(false);
};
return (
<div
className="no-scrollbar"
style={{
width,
height: "100vh",
overflowY: "auto",
background: "#0b1220",
color: "white",
padding: "12px 12px 20px",
borderRight: "1px solid #1f2937",
}}
>
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0b1220", paddingBottom: 10 }}>
<ProjectPanel
sectionTitle={sectionTitle}
projectStatus={projectStatus}
commitCount={commitCount}
latestCommitLabel={latestCommitLabel}
/>
<div style={{ marginTop: 10 }}>
<ImageOverlayPanel
overlay={imageOverlay}
onPickImage={onPickImageOverlay}
onPasteImage={onPasteImageOverlay}
keyboardEnabled={imageOverlayKeyboardEnabled}
onKeyboardEnabledChange={onImageOverlayKeyboardEnabledChange}
onOpacityChange={onImageOverlayOpacityChange}
onRemove={onRemoveImageOverlay}
/>
</div>
<ToolsPanel
mode={mode}
setMode={setMode}
onUndo={onUndo}
/>
{entityStatus ? (
<div
style={{
marginTop: 10,
padding: "10px",
background: "#111827",
borderRadius: 8,
border: "1px solid #7f1d1d",
color: "#fecaca",
fontSize: 12,
overflowWrap: "anywhere",
}}
>
{entityStatus}
</div>
) : null}
</div>
<CommitPanel
commitTitle={commitTitle}
onCommitTitleChange={onCommitTitleChange}
isSaving={isSaving}
isSubmitting={isSubmitting}
changesCount={changesCount}
onCommit={onCommit}
hasHeadCommit={hasHeadCommit}
handleOpenSubmitModal={handleOpenSubmitModal}
/>
<CommitHistoryPanel
commits={commits}
headCommitId={headCommitId}
onRestoreCommit={onRestoreCommit}
isSaving={isSaving}
isSubmitting={isSubmitting}
/>
<UndoListPanel undoStack={undoStack} />
<SubmitModal
isSubmitModalOpen={isSubmitModalOpen}
submitContent={submitContent}
setSubmitContent={setSubmitContent}
handleCancelSubmit={handleCancelSubmit}
handleConfirmSubmit={handleConfirmSubmit}
/>
</div>
);
}
export default memo(Editor);
+699
View File
@@ -0,0 +1,699 @@
"use client";
import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle, memo } from "react";
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { useMapInstance } from "./map/useMapInstance";
import { setupMapLayers } from "./map/useMapLayers";
import { useMapInteraction } from "./map/useMapInteraction";
import { useMapSync } from "./map/useMapSync";
import { bindImageOverlayInteractions, type MapImageOverlay } from "./map/imageOverlay";
import { useMapHoverPopup, type MapHoverPopupContent } from "./map/useMapHoverPopup";
export type MapFeaturePayload = {
featureId: string | number;
feature: Feature | null;
point: { x: number; y: number };
lngLat: { lng: number; lat: number };
};
export type MapHandle = {
getViewState: () => {
center: { lng: number; lat: number };
zoom: number;
pitch: number;
bearing: number;
projection: string;
} | null;
getMap: () => import("maplibre-gl").Map | null;
setGlobeProjection: (isGlobe: boolean) => void;
};
type MapProps = {
mode: EditorMode;
// FeatureCollection that should actually be rendered/interacted with on the map.
// Callers should apply timeline/replay filters before passing it here.
renderDraft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
onSelectFeatureIds: (ids: (string | number)[]) => void;
onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
// Label lookup context only. It may include non-rendered geometries for entity label resolution.
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onAddFeatureToProject?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number | (string | number)[]) => void;
onHideFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean;
applyGeometryBindingFilter?: boolean;
height?: CSSProperties["height"];
fitToDraftBounds?: boolean;
fitBoundsKey?: string | number | null;
onFeatureClick?: ((payload: MapFeaturePayload | null) => void) | undefined;
hoverPopupEnabled?: boolean;
getHoverPopupContent?: (feature: Feature) => MapHoverPopupContent | null;
onHoverFeatureChange?: (feature: Feature | null) => void;
allowFeatureSelection?: boolean;
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | import("maplibre-gl").PaddingOptions;
imageOverlay?: MapImageOverlay | null;
onImageOverlayChange?: (overlay: MapImageOverlay) => void;
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
localFeatureIds?: (string | number)[];
showViewportControls?: boolean;
isPreviewMode?: boolean;
onEnterPreview?: () => void;
onExitPreview?: () => void;
onPlayPreviewReplay?: () => void;
viewMode?: "local" | "global";
onViewModeChange?: (mode: "local" | "global") => void;
onLoad?: () => void;
};
const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
mode,
onSetMode,
renderDraft,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
onSelectFeatureIds,
labelContextDraft,
labelTimelineYear,
onCreateFeature,
onAddFeatureToProject,
onDeleteFeature,
onHideFeature,
onUpdateFeature,
allowGeometryEditing = true,
applyGeometryBindingFilter = true,
height = "100vh",
fitToDraftBounds = false,
fitBoundsKey = null,
onFeatureClick,
hoverPopupEnabled = false,
getHoverPopupContent,
onHoverFeatureChange,
allowFeatureSelection = true,
focusFeatureCollection = null,
focusRequestKey = null,
focusPadding,
imageOverlay = null,
onImageOverlayChange,
onBindGeometries,
localFeatureIds,
showViewportControls = true,
isPreviewMode = false,
onEnterPreview,
onExitPreview,
onPlayPreviewReplay,
viewMode = "local",
onViewModeChange,
onLoad,
}, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode);
// Ref giữ render draft mới nhất để map engines đọc không bị stale closure.
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
// Ref callback select feature mới nhất cho event click trên map.
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
// Ref callback đổi mode mới nhất, dùng khi map interaction chuyển sang replay/select.
const onSetModeRef = useRef(onSetMode);
// Ref callback click feature mới nhất cho tooltip/panel ngoài map.
const onFeatureClickRef = useRef<MapProps["onFeatureClick"]>(onFeatureClick);
const getHoverPopupContentRef = useRef<MapProps["getHoverPopupContent"]>(getHoverPopupContent);
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
// Ref callback create mới nhất khi drawing engine tạo feature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
// Ref callback add geometry global vào project mới nhất cho context menu select.
const onAddFeatureToProjectRef = useRef<MapProps["onAddFeatureToProject"]>(onAddFeatureToProject);
// Ref callback delete mới nhất khi editing engine xóa feature.
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
// Ref callback hide local mới nhất khi context menu select ẩn feature khỏi map.
const onHideRef = useRef<MapProps["onHideFeature"]>(onHideFeature);
// Ref callback update mới nhất khi editing engine đổi geometry.
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
// Ref giữ overlay mới nhất cho right-drag controls.
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay);
// Ref callback update overlay mới nhất để interaction không stale.
const onImageOverlayChangeRef = useRef<MapProps["onImageOverlayChange"]>(onImageOverlayChange);
// Ref callback bind geometry mới nhất để interaction không stale.
const onBindGeometriesRef = useRef<MapProps["onBindGeometries"]>(onBindGeometries);
// Ref danh sách geometry thuộc local project để context menu phân biệt global-only feature.
const localFeatureIdsRef = useRef<MapProps["localFeatureIds"]>(localFeatureIds);
modeRef.current = mode;
renderDraftRef.current = renderDraft;
onSelectFeatureIdsRef.current = onSelectFeatureIds;
onSetModeRef.current = onSetMode;
onFeatureClickRef.current = onFeatureClick;
getHoverPopupContentRef.current = getHoverPopupContent;
onHoverFeatureChangeRef.current = onHoverFeatureChange;
onCreateRef.current = onCreateFeature;
onAddFeatureToProjectRef.current = onAddFeatureToProject;
onDeleteRef.current = onDeleteFeature;
onHideRef.current = onHideFeature;
onUpdateRef.current = onUpdateFeature;
imageOverlayRef.current = imageOverlay;
onImageOverlayChangeRef.current = onImageOverlayChange;
onBindGeometriesRef.current = onBindGeometries;
localFeatureIdsRef.current = localFeatureIds;
useEffect(() => {
// Dynamically import MapLibre CSS to prevent it from blocking initial layout bundle CSS load.
import("maplibre-gl/dist/maplibre-gl.css");
}, []);
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
const {
mapRef,
containerRef,
fatalInitError,
zoomLevel,
zoomBounds,
isGlobeProjection,
setIsGlobeProjection,
isMapLoaded,
geolocationCenteredRef,
handleZoomByStep,
handleZoomSliderChange,
beginZoomSliderDrag,
endZoomSliderDrag,
getViewState,
} = useMapInstance();
// Public API cho parent đọc map instance/view state mà không expose implementation nội bộ.
useImperativeHandle(ref, () => ({
getViewState,
getMap: () => mapRef.current,
setGlobeProjection: (isGlobe: boolean) => {
setIsGlobeProjection(isGlobe);
},
}), [getViewState, mapRef, setIsGlobeProjection]);
// Hook gắn/dọn các interaction vẽ, chọn, sửa geometry.
const {
editingEngineRef,
setupMapInteractions,
cleanupMapInteractions,
} = useMapInteraction({
mapRef,
mode,
modeRef,
renderDraftRef,
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
onSetModeRef,
onCreateRef,
onDeleteRef,
onHideRef,
onUpdateRef,
onFeatureClickRef,
onBindGeometriesRef,
localFeatureIdsRef,
onAddFeatureToProjectRef,
allowFeatureSelection,
});
// Hook đồng bộ draft/layer/filter/highlight từ React state xuống MapLibre source/layer.
const {
applyRenderDraftToMap,
applyImageOverlayToMap,
tryCenterToUserLocation,
} = useMapSync({
mapRef,
renderDraft,
labelContextDraft,
labelTimelineYear,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
applyGeometryBindingFilter,
fitToDraftBounds,
fitBoundsKey,
focusFeatureCollection,
focusRequestKey,
focusPadding,
imageOverlay,
allowGeometryEditing,
editingEngineRef,
geolocationCenteredRef,
isPreviewMode: isPreviewMode || mode === "preview" || mode === "replay" || mode === "replay_preview",
});
useMapHoverPopup({
mapRef,
enabled: hoverPopupEnabled,
renderDraftRef,
getContentRef: getHoverPopupContentRef,
onHoverFeatureChangeRef,
});
useEffect(() => {
const map = mapRef.current;
if (!map || !isMapLoaded) return;
setupMapLayers(map, backgroundVisibility);
applyImageOverlayToMap();
setupMapInteractions(map);
applyRenderDraftToMap(renderDraftRef.current);
tryCenterToUserLocation();
return () => {
cleanupMapInteractions();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMapLoaded]);
useEffect(() => {
const map = mapRef.current;
if (map && isMapLoaded) {
// Trigger resize after a short delay to allow layout to settle
setTimeout(() => map.resize(), 100);
}
}, [mode, isMapLoaded, mapRef]);
useEffect(() => {
if (isMapLoaded && onLoad) {
onLoad();
}
}, [isMapLoaded, onLoad]);
const hasImageOverlay = Boolean(imageOverlay);
useEffect(() => {
const map = mapRef.current;
if (!map || !isMapLoaded || !hasImageOverlay) return;
return bindImageOverlayInteractions(
map,
() => imageOverlayRef.current,
(nextOverlay) => onImageOverlayChangeRef.current?.(nextOverlay)
);
}, [hasImageOverlay, isMapLoaded, mapRef]);
return (
<div style={{ width: "100%", height, position: "relative" }}>
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
position: "relative",
zIndex: 1,
backgroundColor: "transparent"
}}
/>
{fatalInitError ? (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 50,
display: "grid",
placeItems: "center",
padding: "24px",
background: "rgba(2, 6, 23, 0.78)",
color: "#e2e8f0",
}}
>
<div
style={{
maxWidth: "680px",
border: "1px solid rgba(148, 163, 184, 0.3)",
borderRadius: "12px",
background: "rgba(15, 23, 42, 0.92)",
padding: "14px 16px",
}}
>
<div style={{ fontWeight: 800, marginBottom: "6px" }}>
Không khởi tạo đưc bản đ
</div>
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
{fatalInitError}
</div>
</div>
</div>
) : null}
{showViewportControls ? (
<div
style={{
position: "absolute",
top: "10px",
left: "16px",
right: "16px",
zIndex: 12,
pointerEvents: "none",
}}
>
<div
style={{
width: "fit-content",
maxWidth: "95%",
margin: "0 auto",
display: "flex",
alignItems: "center",
gap: "10px",
background: "linear-gradient(135deg, rgba(30, 30, 30, 0.72) 0%, rgba(20, 20, 20, 0.85) 100%)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "50px",
padding: "8px 16px",
color: "#f8fafc",
boxShadow: "0 10px 30px -10px rgba(0, 0, 0, 0.5), inset 0 1px 1px 0 rgba(255, 255, 255, 0.05)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
pointerEvents: "auto",
}}
>
<style dangerouslySetInnerHTML={{ __html: `
.premium-zoom-btn {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: grid;
place-items: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.premium-zoom-btn:hover {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.15);
}
.premium-zoom-btn:active {
background: rgba(16, 185, 129, 0.25);
border-color: #10b981;
}
.premium-zoom-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
min-width: 80px;
height: 24px;
background: transparent;
cursor: pointer;
outline: none;
}
@media (max-width: 768px) {
.premium-zoom-slider {
display: none !important;
}
}
.premium-zoom-slider::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
.premium-zoom-slider:hover::-webkit-slider-runnable-track {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.1);
}
.premium-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -6px;
height: 18px;
width: 18px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%);
border: 1.5px solid #ffffff;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.4), 0 3px 6px rgba(0, 0, 0, 0.15), inset 0 1px 1px rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease;
}
.premium-zoom-slider:hover::-webkit-slider-thumb {
transform: scale(1.2);
box-shadow: 0 0 15px rgba(16, 185, 129, 0.6), 0 5px 10px rgba(0, 0, 0, 0.18), inset 0 1px 1px rgba(255, 255, 255, 0.5);
}
.premium-toggle-track {
width: 38px;
height: 20px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
position: relative;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
flex: 0 0 auto;
}
.premium-toggle-track.active {
background: rgba(52, 211, 153, 0.35);
border-color: rgba(16, 185, 129, 0.6);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.35), inset 0 1px 2px rgba(0, 0, 0, 0.2);
}
.premium-toggle-thumb {
position: absolute;
top: 1.5px;
left: 2px;
width: 15px;
height: 15px;
border-radius: 50%;
background: #94a3b8;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.premium-toggle-track.active .premium-toggle-thumb {
left: 19px;
background: #34d399;
box-shadow: 0 0 10px rgba(52, 211, 153, 0.6), 0 2px 4px rgba(0, 0, 0, 0.25);
}
`}} />
<label
title={
isGlobeProjection
? "Đang ở chế độ hình cầu"
: "Đang ở chế độ bản đồ phẳng"
}
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
padding: "0 2px",
userSelect: "none",
cursor: "pointer",
flexShrink: 0,
}}
>
<input
type="checkbox"
checked={isGlobeProjection}
onChange={(e) => setIsGlobeProjection(e.target.checked)}
aria-label="Chuyển chế độ hiển thị hình cầu"
style={{ display: "none" }}
/>
<div className={`premium-toggle-track ${isGlobeProjection ? "active" : ""}`}>
<span className="premium-toggle-thumb" />
</div>
<span
style={{
fontSize: "12px",
color: isGlobeProjection ? "#ffffff" : "#94a3b8",
fontWeight: 700,
minWidth: "40px",
transition: "color 0.25s ease",
}}
className="hidden sm:block"
>
{isGlobeProjection ? "Cầu" : "Phẳng"}
</span>
</label>
{onViewModeChange ? (
<div style={{ display: "flex", background: "rgba(255, 255, 255, 0.08)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(255, 255, 255, 0.15)", gap: "2px", flexShrink: 0 }}>
<button
type="button"
onClick={() => onViewModeChange("local")}
style={{
padding: "4px 10px",
borderRadius: "999px",
fontSize: "12px",
fontWeight: 700,
background: viewMode === "local" ? "#2563eb" : "transparent",
color: viewMode === "local" ? "white" : "#94a3b8",
border: "none",
cursor: "pointer",
transition: "background 150ms, color 150ms",
}}
>
CỤC BỘ
</button>
<button
type="button"
onClick={() => onViewModeChange("global")}
style={{
padding: "4px 10px",
borderRadius: "999px",
fontSize: "12px",
fontWeight: 700,
background: viewMode === "global" ? "#2563eb" : "transparent",
color: viewMode === "global" ? "white" : "#94a3b8",
border: "none",
cursor: "pointer",
transition: "background 150ms, color 150ms",
}}
>
TOÀN CỤC
</button>
</div>
) : null}
{onEnterPreview || onExitPreview ? (
<button
type="button"
onClick={isPreviewMode ? onExitPreview : onEnterPreview}
className="premium-zoom-btn"
style={{
width: "auto",
minWidth: "76px",
padding: "0 12px",
background: isPreviewMode ? "rgba(51, 65, 85, 0.6)" : "rgba(16, 185, 129, 0.25)",
borderColor: isPreviewMode ? "rgba(255,255,255,0.15)" : "#10b981",
color: isPreviewMode ? "#ffffff" : "#34d399",
flexShrink: 0,
}}
aria-label={isPreviewMode ? "Thoát xem trước" : "Vào chế độ xem trước"}
title={isPreviewMode ? "Thoát xem trước" : "Xem như người dùng"}
>
{isPreviewMode ? "Trình sửa" : "Xem trước"}
</button>
) : null}
{onPlayPreviewReplay ? (
<button
type="button"
onClick={onPlayPreviewReplay}
className="premium-zoom-btn"
style={{
width: "auto",
minWidth: "64px",
padding: "0 12px",
display: "inline-flex",
gap: "7px",
background: "rgba(56, 189, 248, 0.15)",
borderColor: "rgba(56, 189, 248, 0.4)",
color: "#38bdf8",
fontSize: "13px",
flexShrink: 0,
}}
aria-label="Phát diễn biến đã chọn"
title="Phát diễn biến của hình đang chọn"
>
<span
aria-hidden="true"
style={{
width: 0,
height: 0,
borderTop: "5px solid transparent",
borderBottom: "5px solid transparent",
borderLeft: "8px solid currentColor",
}}
/>
Phát
</button>
) : null}
<div className="hidden sm:flex items-center gap-[10px] flex-shrink-0">
<button
type="button"
onClick={() => handleZoomByStep(-0.8)}
className="premium-zoom-btn"
style={{ flexShrink: 0 }}
aria-label="Thu nhỏ bản đồ"
>
-
</button>
<input
type="range"
min={zoomBounds.min}
max={zoomBounds.max}
step={0.1}
value={zoomLevel}
className="premium-zoom-slider"
onPointerDown={(event) => {
event.stopPropagation();
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Browser may reject capture for non-primary pointers; drag lock still works.
}
beginZoomSliderDrag();
}}
onPointerUp={(event) => {
event.stopPropagation();
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Ignore if capture was already released.
}
endZoomSliderDrag();
}}
onPointerCancel={endZoomSliderDrag}
onBlur={endZoomSliderDrag}
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
aria-label="Mức thu phóng bản đồ"
/>
<button
type="button"
onClick={() => handleZoomByStep(0.8)}
className="premium-zoom-btn"
style={{ flexShrink: 0 }}
aria-label="Phóng to bản đồ"
>
+
</button>
<div
style={{
minWidth: "48px",
textAlign: "right",
fontSize: "12px",
fontWeight: 700,
color: "#94a3b8",
fontVariantNumeric: "tabular-nums",
flexShrink: 0,
}}
>
{zoomLevel.toFixed(1)}x
</div>
</div>
</div>
</div>
) : null}
</div>
);
}));
export default Map;
const zoomButtonStyle: React.CSSProperties = {
width: "28px",
height: "28px",
borderRadius: "999px",
border: "1px solid #334155",
background: "#1e293b",
color: "#f8fafc",
fontSize: "18px",
lineHeight: "1",
cursor: "pointer",
display: "grid",
placeItems: "center",
};
@@ -0,0 +1,181 @@
"use client";
import { ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
import { persistBackgroundLayerVisibility } from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/map/styles/backgroundLayers";
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
import { useEditorStore } from "@/uhm/store/editorStore";
type Props = {
topContent?: ReactNode;
width?: number;
};
export default function BackgroundLayersPanel({
topContent,
width = 240,
}: Props) {
const {
visibility,
setBackgroundVisibility,
geometryVisibility,
setGeometryVisibility,
} = useEditorStore(
useShallow((state) => ({
visibility: state.backgroundVisibility,
setBackgroundVisibility: state.setBackgroundVisibility,
geometryVisibility: state.geometryVisibility,
setGeometryVisibility: state.setGeometryVisibility,
}))
);
const updateBackgroundVisibility = (
updater: (prev: typeof visibility) => typeof visibility
) => {
setBackgroundVisibility((prev) => {
const next = updater(prev);
persistBackgroundLayerVisibility(next);
return next;
});
};
const handleToggleLayer = (id: BackgroundLayerId) => {
updateBackgroundVisibility((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const handleShowAll = () => {
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
};
const handleHideAll = () => {
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
};
return (
<aside
className="no-scrollbar"
style={{
width,
background: "#111827",
color: "#e5e7eb",
borderLeft: "1px solid #1f2937",
padding: "12px",
height: "100vh",
overflowY: "auto",
}}
>
{topContent ? <div style={{ marginBottom: "12px" }}>{topContent}</div> : null}
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
const on = Boolean(visibility[layer.id]);
return (
<button
key={layer.id}
type="button"
onClick={() => handleToggleLayer(layer.id)}
style={{
border: "none",
background: "transparent",
padding: 0,
margin: 0,
cursor: "pointer",
color: on ? "#22c55e" : "#e5e7eb",
textDecorationLine: on ? "none" : "line-through",
textDecorationThickness: on ? undefined : "2px",
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
fontSize: 13,
fontWeight: 750,
whiteSpace: "nowrap",
}}
title={on ? "On" : "Off"}
>
{layer.label}
</button>
);
})}
</div>
<div style={{ marginTop: 10, display: "flex", gap: 8 }}>
<button
type="button"
onClick={handleShowAll}
style={secondaryButtonStyle}
>
Show all
</button>
<button
type="button"
onClick={handleHideAll}
style={secondaryButtonStyle}
>
Hide all
</button>
</div>
<>
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
Geometries
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
{GEO_TYPE_KEYS.map((typeKey) => {
const on = geometryVisibility[typeKey] !== false;
return (
<button
key={typeKey}
type="button"
onClick={() => {
setGeometryVisibility((prev) => ({
...prev,
[typeKey]: prev[typeKey] === false,
}));
}}
style={{
border: "none",
background: "transparent",
padding: 0,
margin: 0,
cursor: "pointer",
color: on ? "#22c55e" : "#e5e7eb",
textDecorationLine: on ? "none" : "line-through",
textDecorationThickness: on ? undefined : "2px",
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
fontSize: 13,
fontWeight: 750,
whiteSpace: "nowrap",
textTransform: "capitalize",
}}
title={on ? "On" : "Off"}
>
{typeKey.replaceAll("_", " ")}
</button>
);
})}
</div>
</>
</aside>
);
}
const secondaryButtonStyle = {
border: "1px solid #334155",
borderRadius: 6,
background: "#0b1220",
color: "#cbd5e1",
cursor: "pointer",
fontSize: 12,
fontWeight: 700,
padding: "6px 8px",
} as const;
@@ -0,0 +1,89 @@
import { Panel } from "./Panel";
type Commit = {
id: string;
created_at?: string;
edit_summary: string;
user_id: string;
};
type CommitHistoryPanelProps = {
commits: Commit[];
headCommitId: string | null;
onRestoreCommit: (commitId: string) => void;
isSaving: boolean;
isSubmitting: boolean;
};
export function CommitHistoryPanel({
commits,
headCommitId,
onRestoreCommit,
isSaving,
isSubmitting,
}: CommitHistoryPanelProps) {
const formatCommitTitle = (commit: Commit) =>
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
return (
<Panel title="Commit History" badge={String(commits.length)} defaultOpen={false}>
{commits.length === 0 ? (
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa commit</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
{commits.slice(0, 8).map((commit) => {
const isHead = Boolean(headCommitId && commit.id === headCommitId);
return (
<li
key={commit.id}
style={{
padding: "8px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
display: "flex",
flexDirection: "row"
}}
>
<div style={{flex:1}}>
<div
title={formatCommitTitle(commit)}
style={{
fontWeight: 750,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: 3, color: "#94a3b8" }}>
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
</div>
</div>
<button
style={{
marginTop: 6,
padding: "6px 8px",
borderRadius: 6,
border: "1px solid #334155",
background: isHead ? "#0b1220" : "#334155",
color: "white",
cursor: isSaving || isSubmitting || isHead ? "not-allowed" : "pointer",
opacity: isHead ? 0.65 : 1,
fontWeight: 800,
fontSize: 12,
}}
onClick={() => onRestoreCommit(commit.id)}
disabled={isSaving || isSubmitting || isHead}
title={isHead ? "Đang là head commit" : "Restore snapshot từ commit này (FE-only)"}
>
Restore
</button>
</li>
);
})}
</ul>
)}
</Panel>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { Panel } from "./Panel";
type CommitPanelProps = {
commitTitle: string;
onCommitTitleChange: (title: string) => void;
isSaving: boolean;
isSubmitting: boolean;
changesCount: number;
onCommit: () => void;
hasHeadCommit: boolean;
handleOpenSubmitModal: () => void;
};
export function CommitPanel({
commitTitle,
onCommitTitleChange,
isSaving,
isSubmitting,
changesCount,
onCommit,
hasHeadCommit,
handleOpenSubmitModal,
}: CommitPanelProps) {
const primaryButtonStyle = {
width: "100%",
padding: "8px 10px",
borderRadius: 6,
border: "none",
cursor: "pointer",
fontWeight: 850,
fontSize: 12,
} as const;
const textInputStyle = {
width: "100%",
marginTop: 0,
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "white",
boxSizing: "border-box",
fontSize: 13,
outline: "none",
} as const;
return (
<Panel title="Commit" defaultOpen>
<input
value={commitTitle}
onChange={(event) => onCommitTitleChange(event.target.value)}
placeholder="Edit Summary (Commit Title)"
disabled={isSaving || isSubmitting}
style={textInputStyle}
/>
<button
style={{
...primaryButtonStyle,
marginTop: 8,
background: isSaving || isSubmitting || changesCount <= 0 ? "#475569" : "#0f766e",
cursor: isSaving || isSubmitting || changesCount <= 0 ? "not-allowed" : "pointer",
opacity: changesCount <= 0 ? 0.75 : 1,
}}
onClick={onCommit}
disabled={isSaving || isSubmitting || changesCount <= 0}
title={changesCount <= 0 ? "Khong co thay doi de commit" : undefined}
>
Commit ({changesCount})
</button>
<button
style={{
...primaryButtonStyle,
marginTop: 8,
background: isSubmitting || !hasHeadCommit ? "#475569" : "#16a34a",
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
opacity: !hasHeadCommit ? 0.6 : 1,
}}
onClick={handleOpenSubmitModal}
disabled={isSubmitting || !hasHeadCommit}
>
Submit
</button>
</Panel>
);
}
@@ -0,0 +1,281 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { EntityGeometriesSearchItem, EntityGeometrySearchGeo } from "@/uhm/api/geometries";
import type { Wiki } from "@/uhm/api/wikis";
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar";
type EditorSearchResultsProps = {
searchKind: UnifiedSearchKind;
onSearchKindChange: (kind: UnifiedSearchKind) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onLocalSearchQueryChange: (query: string) => void;
searchQueryDraft: string;
entitySearchResults: Entity[];
isEntitySearchLoading: boolean;
onAddEntityRefToProject: (entity: Entity) => void;
wikiSearchResults: Wiki[];
isWikiSearching: boolean;
onAddWikiRefToProject: (wiki: Wiki) => void;
geoSearchResults: EntityGeometriesSearchItem[];
isGeoSearching: boolean;
onImportGeoFromSearch: (
entityItem: EntityGeometriesSearchItem,
geo: EntityGeometrySearchGeo
) => void;
};
export function EditorSearchResults({
searchKind,
onSearchKindChange,
searchQuery,
onSearchQueryChange,
onLocalSearchQueryChange,
searchQueryDraft,
entitySearchResults,
isEntitySearchLoading,
onAddEntityRefToProject,
wikiSearchResults,
isWikiSearching,
onAddWikiRefToProject,
geoSearchResults,
isGeoSearching,
onImportGeoFromSearch,
}: EditorSearchResultsProps) {
// Draft query quyết định có render kết quả hay không; query chính đã debounce ở page.
const hasQuery = searchQueryDraft.trim().length > 0;
return (
<>
<UnifiedSearchBar
kind={searchKind}
onKindChange={onSearchKindChange}
query={searchQuery}
onQueryChange={onSearchQueryChange}
onLocalQueryChange={onLocalSearchQueryChange}
/>
{searchKind === "entity" && hasQuery ? (
<SearchBox
title="Entity Results"
status={isEntitySearchLoading ? "Searching..." : `${entitySearchResults.length} results`}
>
{entitySearchResults.slice(0, 8).map((entity) => (
<ResultRow
key={entity.id}
title={entity.name}
subtitle={entity.id}
actionLabel="Add"
actionTitle="Add entity ref to project snapshot"
onAction={() => onAddEntityRefToProject(entity)}
/>
))}
{!isEntitySearchLoading && entitySearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
{searchKind === "wiki" && hasQuery ? (
<SearchBox
title="Wiki Results"
status={isWikiSearching ? "Searching..." : `${wikiSearchResults.length} results`}
>
{wikiSearchResults.slice(0, 8).map((wiki) => (
<ResultRow
key={wiki.id}
title={(wiki.title || "").trim() || "Untitled wiki"}
subtitle={wiki.id}
actionLabel="Add"
actionTitle="Add wiki ref to project snapshot"
onAction={() => onAddWikiRefToProject(wiki)}
/>
))}
{!isWikiSearching && wikiSearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
{searchKind === "geo" && hasQuery ? (
<SearchBox
title="Geo Results"
status={isGeoSearching ? "Searching..." : `${geoSearchResults.length} entities`}
>
{geoSearchResults.slice(0, 6).map((item) => (
<GeoResultGroup
key={item.entity_id}
item={item}
onImportGeoFromSearch={onImportGeoFromSearch}
/>
))}
{!isGeoSearching && geoSearchResults.length === 0 ? <EmptyResult /> : null}
</SearchBox>
) : null}
</>
);
}
function SearchBox({
title,
status,
children,
}: {
title: string;
status: string;
children: ReactNode;
}) {
return (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>{title}</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{status}</div>
</div>
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>{children}</div>
</div>
);
}
function ResultRow({
title,
subtitle,
actionLabel,
actionTitle,
onAction,
}: {
title: string;
subtitle: string;
actionLabel: string;
actionTitle: string;
onAction: () => void;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{title}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{subtitle}
</div>
</div>
<button
type="button"
onClick={onAction}
style={actionButtonStyle}
title={actionTitle}
>
{actionLabel}
</button>
</div>
);
}
function GeoResultGroup({
item,
onImportGeoFromSearch,
}: {
item: EntityGeometriesSearchItem;
onImportGeoFromSearch: (
entityItem: EntityGeometriesSearchItem,
geo: EntityGeometrySearchGeo
) => void;
}) {
const geometries = Array.isArray(item.geometries) ? item.geometries : [];
return (
<div
style={{
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
display: "grid",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.name?.trim() || item.entity_id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.entity_id}
</div>
</div>
<div style={{ fontSize: 12, color: "#94a3b8", flex: "0 0 auto" }}>
{geometries.length} geos
</div>
</div>
{item.description?.trim() ? (
<div style={{ color: "#cbd5e1", fontSize: 12, lineHeight: 1.35 }}>
{item.description.trim()}
</div>
) : null}
{geometries.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
{geometries.map((geo) => (
<div
key={geo.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #243244",
background: "#0f172a",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
#{geo.id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11 }}>
type: {geo.type || "unknown"}{" "}
{geo.time_start != null || geo.time_end != null
? `| time: ${geo.time_start ?? "?"} -> ${geo.time_end ?? "?"}`
: ""}
</div>
</div>
<button
type="button"
onClick={() => onImportGeoFromSearch(item, geo)}
style={{ ...actionButtonStyle, flex: "0 0 auto" }}
title="Import geometry into current editor draft"
>
Import
</button>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No geometry linked.</div>
)}
</div>
);
}
function EmptyResult() {
return <div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>;
}
const actionButtonStyle: CSSProperties = {
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
};
@@ -0,0 +1,480 @@
"use client";
import { useMemo, useState, memo } from "react";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
import { useEditorStore } from "@/uhm/store/editorStore";
type EntityChoice = { id: string; name: string; isNew?: boolean };
type WikiChoice = { id: string; title: string; isNew?: boolean };
type BindingRow = {
entityId: string;
entityName: string;
entityIsNew: boolean;
wikiId: string;
wikiTitle: string;
wikiIsNew: boolean;
linkIsNew: boolean;
};
type Props = {
setLinks: React.Dispatch<React.SetStateAction<EntityWikiLinkSnapshot[]>>;
};
function wikiTitle(w: WikiSnapshot): string {
const t = String(w.title || "").trim();
return t.length ? t : "Untitled wiki";
}
function EntityWikiBindingsPanel({ setLinks }: Props) {
const {
entityCatalog,
snapshotEntityRows,
wikis,
links,
} = useEditorStore(
useShallow((state) => ({
entityCatalog: state.entityCatalog,
snapshotEntityRows: state.snapshotEntityRows,
wikis: state.snapshotWikis,
links: state.snapshotEntityWikiLinks,
}))
);
const [activeEntityId, setActiveEntityId] = useState<string>("");
const [activeWikiId, setActiveWikiId] = useState<string>("");
const [collapsed, setCollapsed] = useState(false);
const wikiChoices: WikiChoice[] = useMemo(
() =>
(wikis || [])
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
.map((w) => ({
id: w.id,
title: wikiTitle(w),
isNew: w.source === "inline" && w.operation === "create",
})),
[wikis]
);
const entityChoices = useMemo<EntityChoice[]>(() => {
const visibleSnapshotEntityRows = new globalThis.Map<string, { id: string; name: string; isNew: boolean }>();
for (const ref of snapshotEntityRows || []) {
const id = String(ref?.id || "").trim();
if (!id || ref?.operation === "delete" || visibleSnapshotEntityRows.has(id)) continue;
visibleSnapshotEntityRows.set(id, {
id,
name: String(ref?.name || id),
isNew: ref?.source === "inline" && ref?.operation === "create",
});
}
const rows = Array.from(visibleSnapshotEntityRows.values()).map((entity) => {
const found = entityCatalog.find((item) => String(item.id) === entity.id) || null;
return {
id: entity.id,
name: String(found?.name || entity.name || entity.id),
isNew: entity.isNew,
};
});
rows.sort((a, b) => a.name.localeCompare(b.name));
return rows;
}, [entityCatalog, snapshotEntityRows]);
const activeLinks = useMemo(() => {
const set = new Set<string>();
for (const l of links || []) {
if (!l || l.entity_id !== activeEntityId) continue;
if (l.operation === "delete") continue;
set.add(l.wiki_id);
}
return set;
}, [activeEntityId, links]);
const activeBindingRows = useMemo<BindingRow[]>(() => {
const byKey = new Map<string, EntityWikiLinkSnapshot>();
for (const link of links || []) {
const entityId = String(link?.entity_id || "").trim();
const wikiId = String(link?.wiki_id || "").trim();
if (!entityId || !wikiId) continue;
if (link.operation === "delete") continue;
byKey.set(`${entityId}::${wikiId}`, link);
}
const rows = Array.from(byKey.values()).map((link) => {
const entityId = String(link.entity_id);
const wikiId = String(link.wiki_id);
const entity = entityChoices.find((item) => item.id === entityId) || null;
const wiki = wikiChoices.find((item) => item.id === wikiId) || null;
return {
entityId,
entityName: entity?.name || entityId,
entityIsNew: Boolean(entity?.isNew),
wikiId,
wikiTitle: wiki?.title || wikiId,
wikiIsNew: Boolean(wiki?.isNew),
linkIsNew: link.operation === "binding",
};
});
rows.sort((a, b) => {
if (a.linkIsNew !== b.linkIsNew) return a.linkIsNew ? -1 : 1;
const entityCompare = a.entityName.localeCompare(b.entityName);
if (entityCompare !== 0) return entityCompare;
return a.wikiTitle.localeCompare(b.wikiTitle);
});
return rows;
}, [entityChoices, links, wikiChoices]);
const toggle = (wikiId: string) => {
if (!activeEntityId) return;
const id = String(wikiId || "").trim();
if (!id) return;
setLinks((prev) => {
const idx = prev.findIndex((l) => l.entity_id === activeEntityId && l.wiki_id === id);
// If link exists (reference/binding), unlink by removing the row entirely.
if (idx >= 0 && prev[idx]?.operation !== "delete") {
return prev.filter((_, i) => i !== idx);
}
// If link doesn't exist, add as a new binding (create for relation).
return [
...prev.filter((l) => !(l.entity_id === activeEntityId && l.wiki_id === id)),
{ entity_id: activeEntityId, wiki_id: id, operation: "binding" },
];
});
};
const activeWikiLinked = activeEntityId && activeWikiId ? activeLinks.has(activeWikiId) : false;
const activeWikiChoice = activeWikiId ? wikiChoices.find((w) => w.id === activeWikiId) || null : null;
const activeEntityChoice = activeEntityId ? entityChoices.find((e) => e.id === activeEntityId) || null : null;
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entity Wiki</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{activeBindingRows.length}</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
title={collapsed ? "Mo panel" : "Thu gon panel"}
aria-label={collapsed ? "Mo panel Entity Wiki" : "Thu gon panel Entity Wiki"}
>
{collapsed ? <PlusIcon /> : <MinusIcon />}
</button>
</div>
</div>
{collapsed ? null : (
<div style={{ marginTop: "10px", display: "grid", gap: "8px" }}>
<div>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Entity</div>
<select
value={activeEntityId}
onChange={(e) => setActiveEntityId(e.target.value)}
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
}}
>
<option value="">Select entity</option>
{entityChoices.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
{activeEntityId ? (
<ActiveSelectionLabel
label={activeEntityChoice?.name || activeEntityId}
id={activeEntityId}
isNew={Boolean(activeEntityChoice?.isNew)}
/>
) : null}
</div>
<div>
<div style={{ fontSize: "12px", color: "#94a3b8", marginBottom: "6px" }}>Wikis</div>
<div style={{ display: "grid", gap: "8px" }}>
<select
value={activeWikiId}
onChange={(e) => setActiveWikiId(e.target.value)}
disabled={wikiChoices.length === 0}
style={{
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: "6px",
padding: "8px 10px",
fontSize: "12px",
outline: "none",
opacity: wikiChoices.length === 0 ? 0.7 : 1,
cursor: wikiChoices.length === 0 ? "not-allowed" : "pointer",
}}
>
<option value="">
{wikiChoices.length === 0 ? "No wikis available" : "Select wiki…"}
</option>
{wikiChoices.map((w) => (
<option key={w.id} value={w.id}>
{w.title}
</option>
))}
</select>
{activeWikiChoice ? (
<ActiveSelectionLabel
label={activeWikiChoice.title}
id={activeWikiChoice.id}
isNew={Boolean(activeWikiChoice.isNew)}
/>
) : null}
{wikiChoices.length === 0 ? (
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
) : (
<>
<button
type="button"
disabled={!activeEntityId || !activeWikiId}
onClick={() => toggle(activeWikiId)}
style={{
border: "none",
borderRadius: "6px",
padding: "8px 10px",
cursor: !activeEntityId || !activeWikiId ? "not-allowed" : "pointer",
background: activeWikiLinked ? "#334155" : "#16a34a",
color: "white",
fontWeight: 800,
fontSize: 12,
opacity: !activeEntityId || !activeWikiId ? 0.65 : 1,
}}
>
{activeWikiLinked ? "Unlink wiki" : "Link wiki"}
</button>
{activeWikiChoice ? (
<div style={{ fontSize: 12, color: "#94a3b8", overflowWrap: "anywhere" }}>
{activeWikiChoice.id}
</div>
) : null}
{!activeEntityId ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
) : activeLinks.size ? (
<div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
{Array.from(activeLinks).map((id) => {
const w = wikiChoices.find((x) => x.id === id) || null;
return (
<div
key={id}
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #1f2937",
background: "#111827",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
title={id}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
color: "#e5e7eb",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: 700,
}}
>
{w?.title || "Untitled wiki"}
</span>
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{id}
</div>
</div>
<button
type="button"
onClick={() => toggle(id)}
style={{
border: "none",
background: "#0b1220",
color: "#fecaca",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 800,
flex: "0 0 auto",
}}
>
Unlink
</button>
</div>
);
})}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
)}
</>
)}
</div>
</div>
<div
style={{
borderTop: "1px solid #1f2937",
paddingTop: 8,
display: "grid",
gap: 6,
}}
>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
All bindings ({activeBindingRows.length})
</div>
{activeBindingRows.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 260, overflowY: "auto", paddingRight: 4 }}>
{activeBindingRows.map((row) => (
<div
key={`${row.entityId}::${row.wikiId}`}
style={{
padding: 8,
borderRadius: 6,
border: row.linkIsNew ? "1px solid rgba(45, 212, 191, 0.55)" : "1px solid #1f2937",
background: row.linkIsNew ? "rgba(20, 184, 166, 0.12)" : "#111827",
display: "grid",
gap: 5,
}}
title={`${row.entityId}${row.wikiId}`}
>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span
style={{
color: "#e5e7eb",
fontSize: 12,
fontWeight: 800,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.entityName}
</span>
{row.entityIsNew ? <NewBadge title="Entity mới trong phiên này" /> : null}
{row.linkIsNew ? <NewBadge title="Binding mới trong phiên này" /> : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ color: "#93c5fd", fontSize: 11, flex: "0 0 auto" }}>Wiki</span>
<span
style={{
color: "#cbd5e1",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.wikiTitle}
</span>
{row.wikiIsNew ? <NewBadge title="Wiki mới trong phiên này" /> : null}
</div>
<div
style={{
color: "#64748b",
fontSize: 11,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.entityId} {row.wikiId}
</div>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No entity-wiki binding yet.</div>
)}
</div>
</div>
)}
</div>
);
}
function ActiveSelectionLabel({
label,
id,
isNew,
}: {
label: string;
id: string;
isNew?: boolean;
}) {
return (
<div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ color: "#cbd5e1", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{label}
</span>
<span style={{ color: "#64748b", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{id}
</span>
{isNew ? <NewBadge /> : null}
</div>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export default memo(EntityWikiBindingsPanel);
@@ -0,0 +1,690 @@
"use client";
import { useMemo, useState, memo, type CSSProperties, type KeyboardEvent } from "react";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
import { useEditorStore } from "@/uhm/store/editorStore";
type GeometryChoice = {
id: string;
label?: string;
time_start?: unknown;
time_end?: unknown;
isTimelineVisible?: boolean;
isOrphan?: boolean;
timeStatus?: GeometryTimeStatus;
timelineStatus?: GeometryTimelineStatus;
isNew?: boolean;
};
type GeometryTimeStatus = "missing" | "partial" | "complete";
type GeometryTimelineStatus = "off" | "visible" | "filteredOut";
type GeometryRow = Required<Pick<GeometryChoice, "id" | "label" | "isOrphan" | "timeStatus" | "timelineStatus" | "isNew">> & {
time_start: number | null;
time_end: number | null;
isTimelineVisible: boolean;
};
type Props = {
geometries: GeometryChoice[];
selectedGeometryId?: string | null;
selectedGeometryChildIds: string[];
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
onFocusGeometry?: (geometryId: string) => void;
};
function GeometryBindingPanel({
geometries,
selectedGeometryId,
selectedGeometryChildIds,
onToggleBindGeometryForSelectedGeometry,
onFocusGeometry,
}: Props) {
const {
selectedFeatureIds,
statusText,
bindingFilterEnabled,
setGeometryBindingFilterEnabled,
geometryVisibility,
setGeometryVisibility,
} = useEditorStore(
useShallow((state) => ({
selectedFeatureIds: state.selectedFeatureIds,
statusText: state.geoBindingStatus,
bindingFilterEnabled: state.geometryBindingFilterEnabled,
setGeometryBindingFilterEnabled: state.setGeometryBindingFilterEnabled,
geometryVisibility: state.geometryVisibility,
setGeometryVisibility: state.setGeometryVisibility,
}))
);
const effectiveSelectedGeometryId =
selectedGeometryId ??
(selectedFeatureIds.length > 0 ? String(selectedFeatureIds[0]) : null);
const canBindToggle =
Boolean(effectiveSelectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
const canFocusGeometry = typeof onFocusGeometry === "function";
const [collapsed, setCollapsed] = useState(false);
const rows = useMemo(() => {
const cleaned = (geometries || [])
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
.map((g) => ({
id: g.id.trim(),
label: (g.label || "").trim(),
time_start: normalizeTimelineYearValue(g.time_start),
time_end: normalizeTimelineYearValue(g.time_end),
isTimelineVisible: Boolean(g.isTimelineVisible),
isOrphan: Boolean(g.isOrphan),
timeStatus: resolveTimeStatus(g),
timelineStatus: resolveTimelineStatus(g),
isNew: Boolean(g.isNew),
}));
cleaned.sort((a, b) => a.id.localeCompare(b.id));
return cleaned;
}, [geometries]);
const childSet = useMemo(() => new Set(selectedGeometryChildIds || []), [selectedGeometryChildIds]);
const selectedGeometry = useMemo(() => {
if (!effectiveSelectedGeometryId) return null;
return rows.find((g) => g.id === effectiveSelectedGeometryId) || null;
}, [effectiveSelectedGeometryId, rows]);
const visibleRows = useMemo(() => {
return rows
.filter((g) => g.id !== effectiveSelectedGeometryId)
.sort((a, b) => {
const aBound = childSet.has(a.id);
const bBound = childSet.has(b.id);
if (aBound !== bBound) return aBound ? -1 : 1;
return a.id.localeCompare(b.id);
});
}, [childSet, effectiveSelectedGeometryId, rows]);
const summary = useMemo(() => {
let orphan = 0;
let missingTime = 0;
let partialTime = 0;
let filteredOut = 0;
let hidden = 0;
for (const row of rows) {
if (row.isOrphan) orphan += 1;
if (row.timeStatus === "missing") missingTime += 1;
if (row.timeStatus === "partial") partialTime += 1;
if (row.timelineStatus === "filteredOut") filteredOut += 1;
if (geometryVisibility[row.id] === false) hidden += 1;
}
return {
total: rows.length,
orphan,
missingTime,
partialTime,
timeIssues: missingTime + partialTime,
filteredOut,
hidden,
};
}, [geometryVisibility, rows]);
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
if (!canFocusGeometry) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onFocusGeometry?.(geometryId);
};
const handleFocusGeometry = (geometryId: string) => {
onFocusGeometry?.(geometryId);
};
const toggleGeometryVisibility = (geometryId: string) => {
setGeometryVisibility((prev) => ({
...prev,
[geometryId]: prev[geometryId] === false,
}));
};
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ display: "flex",flexDirection: "column", gap: 10, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: "14px", whiteSpace: "nowrap" }}>Geometry Binding</div>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
userSelect: "none",
}}
title={bindingFilterEnabled ? "Đang ẩn geo theo binding" : "Đang hiển thị tất cả geo"}
>
<button
type="button"
role="switch"
aria-checked={bindingFilterEnabled}
aria-label="Toggle geometry binding filter"
onClick={() => setGeometryBindingFilterEnabled(!bindingFilterEnabled)}
style={{
width: 32,
height: 18,
padding: 2,
borderRadius: 999,
border: bindingFilterEnabled ? "1px solid #38bdf8" : "1px solid #334155",
background: bindingFilterEnabled ? "rgba(14, 165, 233, 0.32)" : "#111827",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: bindingFilterEnabled ? "flex-end" : "flex-start",
transition: "background 140ms ease, border-color 140ms ease",
}}
>
<span
style={{
width: 12,
height: 12,
borderRadius: 999,
background: bindingFilterEnabled ? "#67e8f9" : "#94a3b8",
boxShadow: bindingFilterEnabled ? "0 0 8px rgba(103, 232, 249, 0.45)" : "none",
transition: "background 140ms ease, box-shadow 140ms ease",
}}
/>
</button>
<span style={{ fontSize: 12, color: "#94a3b8", whiteSpace: "nowrap" }}>Filter binding</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<div style={summaryWrapStyle}>
<span style={summaryBadgeStyle} title="Total geometry count">all {summary.total}</span>
{summary.orphan > 0 ? (
<span style={summaryDangerBadgeStyle} title="Geometry without any bound entity">entity {summary.orphan}</span>
) : null}
{summary.timeIssues > 0 ? (
<span
style={summaryWarningBadgeStyle}
title={`Missing time: ${summary.missingTime}; partial time: ${summary.partialTime}`}
>
time {summary.timeIssues}
</span>
) : null}
{summary.filteredOut > 0 ? (
<span style={summaryMutedBadgeStyle} title="Geometry filtered out by timeline">out {summary.filteredOut}</span>
) : null}
{summary.hidden > 0 ? (
<span style={summaryMutedBadgeStyle} title="Geometry hidden manually">hidden {summary.hidden}</span>
) : null}
</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
title={collapsed ? "Mở panel" : "Thu gọn panel"}
aria-label={collapsed ? "Mở panel Geometry Binding" : "Thu gọn panel Geometry Binding"}
>
{collapsed ? <PlusIcon /> : <MinusIcon />}
</button>
</div>
</div>
{collapsed ? null : selectedGeometry ? (
(() => {
const isHidden = geometryVisibility[selectedGeometry.id] === false;
const isBound = childSet.has(selectedGeometry.id);
const title = buildGeometryTitle(selectedGeometry, isHidden, isBound);
return (
<div
style={{
marginTop: 10,
padding: "8px",
borderRadius: "6px",
border:
"1px solid rgba(59, 130, 246, 0.45)",
background: "rgba(37, 99, 235, 0.12)",
cursor: canFocusGeometry ? "pointer" : "default",
opacity: isHidden ? 0.58 : 1,
boxShadow: "none",
}}
title={title}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(selectedGeometry.id)}
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
>
<div
style={{
fontSize: 10,
color: "#93c5fd",
fontWeight: 900,
textTransform: "uppercase",
lineHeight: 1,
marginBottom: 5,
}}
>
Selected
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<GeometryLabel row={selectedGeometry} color="#dbeafe" />
{selectedGeometry.isNew ? <NewBadge /> : null}
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(selectedGeometry.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${selectedGeometry.id}` : `Hide geometry ${selectedGeometry.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
<StatusChips row={selectedGeometry} isHidden={isHidden} isBound={isBound} />
</div>
);
})()
) : null}
{collapsed ? null : rows.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{visibleRows
.map((g) => {
const isBound = childSet.has(g.id);
const isHidden = geometryVisibility[g.id] === false;
const title = buildGeometryTitle(g, isHidden, isBound);
return (
<div
key={g.id}
style={{
padding: "8px",
borderRadius: "6px",
border: isBound
? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937",
background: isBound
? "rgba(20, 184, 166, 0.12)"
: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
cursor: canFocusGeometry ? "pointer" : "default",
opacity: isHidden ? 0.55 : canBindToggle ? 1 : 0.75,
boxShadow: "none",
}}
title={title}
role={canFocusGeometry ? "button" : undefined}
tabIndex={canFocusGeometry ? 0 : undefined}
onClick={() => handleFocusGeometry(g.id)}
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
minWidth: 0,
}}
>
<GeometryLabel row={g} />
{g.isNew ? <NewBadge /> : null}
</div>
<StatusChips row={g} isHidden={isHidden} isBound={isBound} />
</div>
<button
type="button"
title={isHidden ? "Show geometry on map" : "Hide geometry on map"}
onClick={(event) => {
event.stopPropagation();
toggleGeometryVisibility(g.id);
}}
style={iconButtonStyle}
aria-label={isHidden ? `Show geometry ${g.id}` : `Hide geometry ${g.id}`}
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
{canBindToggle ? (
<button
type="button"
title={isBound ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={(event) => {
event.stopPropagation();
onToggleBindGeometryForSelectedGeometry!(g.id, !isBound);
}}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}}
aria-label={
isBound
? `Unbind geometry ${g.id} from selected geometry`
: `Bind geometry ${g.id} to selected geometry`
}
>
{isBound ? <UnlockIcon /> : <LockIcon />}
</button>
) : null}
</div>
);
})}
</div>
) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
No geometry yet for this project.
</div>
)}
{collapsed ? null : statusText ? (
<div style={{ marginTop: 10, fontSize: 12, color: "#93c5fd" }}>
{statusText}
</div>
) : null}
</div>
);
}
function GeometryLabel({ row, color = "#e5e7eb" }: { row: GeometryRow; color?: string }) {
return (
<span
style={{
fontSize: "12px",
color,
fontWeight: 700,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{row.label || "Geometry"}
</span>
);
}
function StatusChips({ row, isHidden, isBound }: { row: GeometryRow; isHidden: boolean; isBound: boolean }) {
return (
<div style={statusChipRowStyle}>
{row.isOrphan ? <span style={dangerBadgeStyle}>no entity</span> : null}
{row.timeStatus === "missing" ? <span style={dangerBadgeStyle}>no time</span> : null}
{row.timeStatus === "partial" ? <span style={warningBadgeStyle}>partial time</span> : null}
{row.timelineStatus === "visible" ? <span style={timelineBadgeStyle}>timeline</span> : null}
{row.timelineStatus === "filteredOut" ? <span style={mutedBadgeStyle}>out timeline</span> : null}
{isHidden ? <span style={hiddenBadgeStyle}>hidden</span> : null}
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
</div>
);
}
function resolveTimeStatus(geometry: GeometryChoice): GeometryTimeStatus {
if (geometry.timeStatus === "missing" || geometry.timeStatus === "partial" || geometry.timeStatus === "complete") {
return geometry.timeStatus;
}
const hasStart = normalizeTimelineYearValue(geometry.time_start) !== null;
const hasEnd = normalizeTimelineYearValue(geometry.time_end) !== null;
if (!hasStart && !hasEnd) return "missing";
if (!hasStart || !hasEnd) return "partial";
return "complete";
}
function resolveTimelineStatus(geometry: GeometryChoice): GeometryTimelineStatus {
if (
geometry.timelineStatus === "off" ||
geometry.timelineStatus === "visible" ||
geometry.timelineStatus === "filteredOut"
) {
return geometry.timelineStatus;
}
return geometry.isTimelineVisible ? "visible" : "off";
}
function buildGeometryTitle(row: GeometryRow, isHidden: boolean, isBound: boolean): string {
const parts = [`ID: ${row.id}`];
if (row.isOrphan) parts.push("Orphan");
if (row.timeStatus === "missing") parts.push("Missing time");
if (row.timeStatus === "partial") parts.push("Partial time");
if (row.timelineStatus === "visible") parts.push("Timeline visible");
if (row.timelineStatus === "filteredOut") parts.push("Filtered out by timeline");
if (isHidden) parts.push("Hidden");
if (isBound) parts.push("Bound");
if (row.isNew) parts.push("New");
return parts.join(" | ");
}
const summaryWrapStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
minWidth: 0,
flexWrap: "wrap",
};
const baseBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
whiteSpace: "nowrap",
};
const summaryBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.35)",
background: "rgba(15, 23, 42, 0.9)",
color: "#cbd5e1",
};
const summaryDangerBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(248, 113, 113, 0.5)",
background: "rgba(127, 29, 29, 0.32)",
color: "#fecaca",
};
const summaryWarningBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(250, 204, 21, 0.48)",
background: "rgba(113, 63, 18, 0.3)",
color: "#fde68a",
};
const summaryMutedBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.4)",
background: "rgba(51, 65, 85, 0.32)",
color: "#cbd5e1",
};
const statusChipRowStyle: CSSProperties = {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 4,
marginTop: 5,
minHeight: 17,
};
const dangerBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(248, 113, 113, 0.5)",
background: "rgba(127, 29, 29, 0.28)",
color: "#fecaca",
};
const warningBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(250, 204, 21, 0.5)",
background: "rgba(113, 63, 18, 0.28)",
color: "#fde68a",
};
const timelineBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(34, 197, 94, 0.5)",
background: "rgba(20, 83, 45, 0.3)",
color: "#bbf7d0",
};
const mutedBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.28)",
color: "#cbd5e1",
};
const boundBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(45, 212, 191, 0.5)",
background: "rgba(20, 184, 166, 0.18)",
color: "#99f6e4",
};
const hiddenBadgeStyle: CSSProperties = {
...baseBadgeStyle,
border: "1px solid rgba(148, 163, 184, 0.45)",
background: "rgba(71, 85, 105, 0.32)",
color: "#cbd5e1",
};
const iconButtonStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
};
function EyeIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="#cbd5e1" strokeWidth="2" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 3l18 18" stroke="#fca5a5" strokeWidth="2" strokeLinecap="round" />
<path
d="M10.6 6.2A10.5 10.5 0 0 1 12 6c6 0 9.5 6 9.5 6a17 17 0 0 1-2.1 2.8M6.2 8.1A17 17 0 0 0 2.5 12s3.5 6 9.5 6c1.3 0 2.5-.3 3.5-.7"
stroke="#fca5a5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M7 10V8a5 5 0 0 1 10 0v2"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#cbd5e1"
strokeWidth="2"
/>
</svg>
);
}
function UnlockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M17 10V8a5 5 0 0 0-9.5-2"
stroke="#a7f3d0"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#a7f3d0"
strokeWidth="2"
/>
</svg>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export default memo(GeometryBindingPanel);
@@ -0,0 +1,127 @@
"use client";
import type { ChangeEvent } from "react";
import type { MapImageOverlay } from "@/uhm/components/map/imageOverlay";
type Props = {
overlay: MapImageOverlay | null;
onPickImage: (file: File | null) => void;
onPasteImage: () => void;
keyboardEnabled: boolean;
onKeyboardEnabledChange: (enabled: boolean) => void;
onOpacityChange: (opacity: number) => void;
onRemove: () => void;
};
export default function ImageOverlayPanel({
overlay,
onPickImage,
onPasteImage,
keyboardEnabled,
onKeyboardEnabledChange,
onOpacityChange,
onRemove,
}: Props) {
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
onPickImage(file);
event.target.value = "";
};
return (
<section style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div>
<div style={{ fontWeight: 800, fontSize: 13, color: "white" }}>Trace Image</div>
<div style={{ marginTop: 2, fontSize: 11, color: "#94a3b8" }}>
Chuột phải kéo điểm vàng đ di chuyển, điểm xanh đ kéo dãn giữ ratio.
{keyboardEnabled ? " WASD di chuyển, Q/E phóng to/thu nhỏ." : ""}
</div>
</div>
{overlay ? (
<button type="button" onClick={onRemove} style={dangerButtonStyle}>
Remove
</button>
) : null}
</div>
<div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 8 }}>
<label style={uploadButtonStyle}>
{overlay ? "Đổi ảnh" : "Thêm ảnh"}
<input
type="file"
accept="image/*"
onChange={handleFileChange}
style={{ display: "none" }}
/>
</label>
<button type="button" onClick={onPasteImage} style={uploadButtonStyle}>
Paste nh
</button>
<button
type="button"
onClick={() => onKeyboardEnabledChange(!keyboardEnabled)}
disabled={!overlay}
style={{
...uploadButtonStyle,
opacity: overlay ? 1 : 0.5,
color: keyboardEnabled ? "#86efac" : "#93c5fd",
cursor: overlay ? "pointer" : "not-allowed",
}}
title="Bật/tắt điều khiển ảnh bằng WASD và Q/E"
>
Keys: {keyboardEnabled ? "On" : "Off"}
</button>
</div>
{overlay ? (
<div style={{ marginTop: 10, display: "grid", gap: 8 }}>
<div style={{ fontSize: 12, color: "#cbd5e1", overflowWrap: "anywhere" }}>
{overlay.name}
</div>
<label style={{ display: "grid", gap: 6, fontSize: 12, color: "#cbd5e1" }}>
<span>Opacity: {Math.round(overlay.opacity * 100)}%</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={overlay.opacity}
onChange={(event) => onOpacityChange(Number(event.target.value))}
style={{ width: "100%", accentColor: "#38bdf8" }}
/>
</label>
</div>
) : (
<div style={{ marginTop: 8, fontSize: 12, color: "#94a3b8" }}>
Chưa nh overlay.
</div>
)}
</section>
);
}
const uploadButtonStyle = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid #334155",
borderRadius: 6,
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
padding: "7px 10px",
} as const;
const dangerButtonStyle = {
border: "1px solid #7f1d1d",
borderRadius: 6,
background: "#1f1111",
color: "#fecaca",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
padding: "6px 8px",
} as const;
+86
View File
@@ -0,0 +1,86 @@
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
export function ModeHint({ mode }: { mode: EditorMode }) {
if (mode === "add-line" || mode === "add-path") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Click trên bản đ đ thêm đnh.</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Enter</b>: Hoàn tất & Chốt hình</li>
<li><b>Esc</b>: Hủy bỏ thao tác vẽ</li>
<li><b>Backspace</b>: Xóa đnh vừa vẽ cuối cùng</li>
<li><b>Giữ Shift / Alt</b>: Bắt dính (Snap) vào hình khác</li>
</ul>
</div>
);
}
if (mode === "add-circle") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Kéo chuột đ vẽ hình tròn.</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Nhấn giữ chuột trái</b>: Chọn tâm & kéo đ tạo bán kính</li>
<li><b>Nhả chuột trái</b>: Hoàn tất chốt hình</li>
<li><b>Esc</b>: Hủy bỏ thao tác đang kéo vẽ dở</li>
</ul>
</div>
);
}
if (mode === "add-point") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Click trên bản đ đ tạo một Điểm.</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Giữ Shift / Alt</b>: Bắt dính (Snap) chính xác vào hình khác</li>
</ul>
</div>
)
}
if (mode === "select") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Click vào hình trên map đ Chọn (Select).</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Giữ Shift + click</b>: Chọn nhiều hình hoặc bỏ chọn hình đang chọn</li>
<li><b>Chuột phải</b>: Mở menu chỉnh sửa / xóa / duplicate / replay</li>
<li>Trong chế đ Sửa đnh:
<ul style={{ paddingLeft: 16, margin: "2px 0 0 0" }}>
<li><b>Enter</b>: Lưu hình đã sửa</li>
<li><b>Esc</b>: Hủy chỉnh sửa</li>
<li><b>Delete</b>: Bật/Tắt chế đ Xóa đnh (click đ xóa)</li>
<li><b>Giữ Shift</b>: Bắt dính (Snap) điểm đang kéo</li>
</ul>
</li>
</ul>
</div>
)
}
if (mode === "draw") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Click trên bản đ đ vẽ Đa giác (Polygon).</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Enter</b>: Hoàn tất & Chốt hình</li>
<li><b>Esc</b>: Hủy bỏ thao tác vẽ</li>
<li><b>Backspace</b>: Xóa đnh vừa vẽ cuối cùng</li>
<li><b>Giữ Shift / Alt</b>: Bắt dính (Snap) vào hình khác</li>
</ul>
</div>
)
}
if (mode === "replay") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Đang trong chế đ trình diễn diễn biến kịch bản.
</div>
)
}
if (mode === "replay_preview") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Đang xem preview replay trên session tách biệt.
</div>
)
}
return null;
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import type { CSSProperties } from "react";
type Props = {
title?: string;
};
export default function NewBadge({ title = "Created in this session and not committed yet" }: Props) {
return (
<span style={badgeStyle} title={title}>
new
</span>
);
}
const badgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(45, 212, 191, 0.55)",
background: "rgba(20, 184, 166, 0.16)",
color: "#5eead4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
+91
View File
@@ -0,0 +1,91 @@
import { type ReactNode, useState } from "react";
type PanelProps = {
title: string;
badge?: string | null;
defaultOpen?: boolean;
children: ReactNode;
};
export function Panel({
title,
badge,
defaultOpen,
children,
}: PanelProps) {
const [open, setOpen] = useState(Boolean(defaultOpen));
return (
<details
open={open}
onToggle={(e) => setOpen(e.currentTarget.open)}
style={{
marginTop: 10,
padding: 10,
background: "#111827",
borderRadius: 8,
border: "1px solid #1f2937",
}}
>
<summary
style={{
cursor: "pointer",
listStyle: "none",
fontWeight: 900,
fontSize: 13,
color: "white",
userSelect: "none",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span>{title}</span>
{badge && (
<span
style={{
fontSize: 11,
fontWeight: 500,
background: "#374151",
color: "#cbd5e1",
padding: "2px 6px",
borderRadius: 4,
}}
>
{badge}
</span>
)}
</div>
<span
style={{
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#cbd5e1",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
fontWeight: 700,
flex: "0 0 auto",
}}
>
{open ? "" : "+"}
</span>
</summary>
<style>{`
summary::-webkit-details-marker {
display: none !important;
}
summary {
list-style: none !important;
}
`}</style>
<div style={{ marginTop: 10 }}>{children}</div>
</details>
);
}
@@ -0,0 +1,788 @@
"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
import {
fetchPresentPlaceDetail,
hasSearchMapApiKey,
reverseGeocodePresentPlace,
searchPresentPlaces,
type PresentPlacePrediction,
type PresentPlaceSelection,
} from "@/uhm/api/goongPlaces";
import { getGeometryRepresentativePoint } from "@/uhm/components/map/mapUtils";
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
export type { PresentPlaceSelection } from "@/uhm/api/goongPlaces";
export type HistoricalGeometryFocusPayload = {
entity: EntityGeometriesSearchItem;
geometry: EntityGeometrySearchGeo;
representativePoint: [number, number] | null;
adminLabel: string | null;
};
type SearchMode = "present" | "history" | "wiki";
type AdminLabelState = {
status: "loading" | "loaded" | "error";
label: string | null;
address: string | null;
};
type Props = {
focusedPlace: PresentPlaceSelection | null;
onFocusPlace: (place: PresentPlaceSelection) => void;
onFocusHistoricalGeometry: (payload: HistoricalGeometryFocusPayload) => void;
onFocusWiki?: (wiki: Wiki) => void;
onClearFocus: () => void;
leftOffset?: number;
style?: CSSProperties;
};
export default function PresentPlaceSearch({
focusedPlace,
onFocusPlace,
onFocusHistoricalGeometry,
onFocusWiki,
onClearFocus,
leftOffset = 18,
style,
}: Props) {
const [mode, setMode] = useState<SearchMode>("history");
const [query, setQuery] = useState("");
const [results, setResults] = useState<PresentPlacePrediction[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectingPlaceId, setSelectingPlaceId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [historicalQuery, setHistoricalQuery] = useState("");
const [historicalResults, setHistoricalResults] = useState<EntityGeometriesSearchItem[]>([]);
const [isHistoricalLoading, setIsHistoricalLoading] = useState(false);
const [historicalError, setHistoricalError] = useState<string | null>(null);
const [expandedEntityId, setExpandedEntityId] = useState<string | null>(null);
const [selectingGeometryId, setSelectingGeometryId] = useState<string | null>(null);
const [adminLabels, setAdminLabels] = useState<Record<string, AdminLabelState>>({});
const [wikiQuery, setWikiQuery] = useState("");
const [wikiResults, setWikiResults] = useState<Wiki[]>([]);
const [isWikiLoading, setIsWikiLoading] = useState(false);
const [wikiError, setWikiError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const requestSeqRef = useRef(0);
const historicalRequestSeqRef = useRef(0);
const wikiRequestSeqRef = useRef(0);
const hasApiKey = hasSearchMapApiKey();
const activeQuery = mode === "present" ? query : mode === "history" ? historicalQuery : wikiQuery;
const activeError = mode === "present" ? error : mode === "history" ? historicalError : wikiError;
const activeLoading = mode === "present" ? isLoading : mode === "history" ? isHistoricalLoading : isWikiLoading;
const expandedItem = useMemo(() => {
if (!expandedEntityId) return null;
return historicalResults.find((item) => item.entity_id === expandedEntityId) || null;
}, [expandedEntityId, historicalResults]);
useEffect(() => {
if (mode !== "present") return;
const keyword = query.trim();
if (!keyword || keyword.length < 2) {
setResults([]);
setIsLoading(false);
setError(null);
return;
}
if (!hasApiKey) {
setResults([]);
setIsLoading(false);
setError("Thiếu SEARCH_MAP_API_KEY.");
return;
}
const controller = new AbortController();
const seq = requestSeqRef.current + 1;
requestSeqRef.current = seq;
const timer = window.setTimeout(() => {
setIsLoading(true);
setError(null);
searchPresentPlaces(keyword, controller.signal)
.then((nextResults) => {
if (requestSeqRef.current !== seq) return;
setResults(nextResults);
setIsOpen(true);
})
.catch((err) => {
if (controller.signal.aborted || requestSeqRef.current !== seq) return;
setResults([]);
setError(err instanceof Error ? err.message : "Không tìm được địa điểm.");
})
.finally(() => {
if (requestSeqRef.current === seq) {
setIsLoading(false);
}
});
}, 260);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [hasApiKey, mode, query]);
useEffect(() => {
if (mode !== "history") return;
const keyword = historicalQuery.trim();
if (!keyword || keyword.length < 2) {
setHistoricalResults([]);
setIsHistoricalLoading(false);
setHistoricalError(null);
setExpandedEntityId(null);
return;
}
const seq = historicalRequestSeqRef.current + 1;
historicalRequestSeqRef.current = seq;
const timer = window.setTimeout(() => {
setIsHistoricalLoading(true);
setHistoricalError(null);
searchGeometriesByEntityName(keyword, { limit: 12 })
.then((response) => {
if (historicalRequestSeqRef.current !== seq) return;
setHistoricalResults(response.items || []);
setExpandedEntityId(null);
setIsOpen(true);
})
.catch((err) => {
if (historicalRequestSeqRef.current !== seq) return;
setHistoricalResults([]);
setHistoricalError(err instanceof Error ? err.message : "Không tìm được thực thể lịch sử.");
})
.finally(() => {
if (historicalRequestSeqRef.current === seq) {
setIsHistoricalLoading(false);
}
});
}, 260);
return () => window.clearTimeout(timer);
}, [historicalQuery, mode]);
useEffect(() => {
if (mode !== "wiki") return;
const keyword = wikiQuery.trim();
if (!keyword || keyword.length < 2) {
setWikiResults([]);
setIsWikiLoading(false);
setWikiError(null);
return;
}
const seq = wikiRequestSeqRef.current + 1;
wikiRequestSeqRef.current = seq;
const timer = window.setTimeout(() => {
setIsWikiLoading(true);
setWikiError(null);
searchWikisByTitle(keyword, { limit: 12 })
.then((nextResults) => {
if (wikiRequestSeqRef.current !== seq) return;
setWikiResults(nextResults || []);
setIsOpen(true);
})
.catch((err) => {
if (wikiRequestSeqRef.current !== seq) return;
setWikiResults([]);
setWikiError(err instanceof Error ? err.message : "Không tìm được bài viết wiki.");
})
.finally(() => {
if (wikiRequestSeqRef.current === seq) {
setIsWikiLoading(false);
}
});
}, 260);
return () => window.clearTimeout(timer);
}, [wikiQuery, mode]);
useEffect(() => {
if (mode !== "history" || !expandedItem || expandedItem.geometries.length <= 1 || !hasApiKey) {
return;
}
const controller = new AbortController();
for (const geometry of expandedItem.geometries) {
const point = getGeometryRepresentativePoint(geometry.draw_geometry);
if (!point) {
setAdminLabels((prev) => ({
...prev,
[geometry.id]: { status: "error", label: null, address: null },
}));
continue;
}
setAdminLabels((prev) => {
if (prev[geometry.id]) return prev;
return {
...prev,
[geometry.id]: { status: "loading", label: null, address: null },
};
});
reverseGeocodePresentPlace(point[0], point[1], controller.signal)
.then((place) => {
setAdminLabels((prev) => ({
...prev,
[geometry.id]: {
status: "loaded",
label: place.label,
address: place.address,
},
}));
})
.catch((err) => {
if (controller.signal.aborted) return;
console.warn("Reverse geocode historical geometry failed", err);
setAdminLabels((prev) => ({
...prev,
[geometry.id]: { status: "error", label: null, address: null },
}));
});
}
return () => controller.abort();
}, [expandedItem, hasApiKey, mode]);
const selectPrediction = async (prediction: PresentPlacePrediction) => {
setSelectingPlaceId(prediction.placeId);
setError(null);
try {
const place = await fetchPresentPlaceDetail(prediction.placeId);
onFocusPlace(place);
setQuery(place.name || prediction.description);
setResults([]);
setIsOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Không lấy được tọa độ địa điểm.");
} finally {
setSelectingPlaceId(null);
}
};
const selectHistoricalEntity = (item: EntityGeometriesSearchItem) => {
if (item.geometries.length === 1) {
selectHistoricalGeometry(item, item.geometries[0]);
return;
}
if (item.geometries.length > 1) {
setExpandedEntityId((prev) => prev === item.entity_id ? null : item.entity_id);
}
};
const selectHistoricalGeometry = (
item: EntityGeometriesSearchItem,
geometry: EntityGeometrySearchGeo
) => {
setSelectingGeometryId(geometry.id);
const labelState = adminLabels[geometry.id] || null;
onFocusHistoricalGeometry({
entity: item,
geometry,
representativePoint: getGeometryRepresentativePoint(geometry.draw_geometry),
adminLabel: labelState?.label || null,
});
setHistoricalQuery(item.name);
setHistoricalResults([]);
setExpandedEntityId(null);
setIsOpen(false);
setSelectingGeometryId(null);
};
const selectWiki = (wiki: Wiki) => {
if (onFocusWiki) {
onFocusWiki(wiki);
}
setWikiQuery(wiki.title || "");
setWikiResults([]);
setIsOpen(false);
};
const clearSearch = () => {
if (mode === "present") {
setQuery("");
setResults([]);
setError(null);
} else if (mode === "history") {
setHistoricalQuery("");
setHistoricalResults([]);
setHistoricalError(null);
setExpandedEntityId(null);
} else {
setWikiQuery("");
setWikiResults([]);
setWikiError(null);
}
setIsOpen(false);
onClearFocus();
};
const cycleMode = () => {
let nextMode: SearchMode;
if (mode === "history") {
nextMode = "present";
} else if (mode === "present") {
nextMode = "wiki";
} else {
nextMode = "history";
}
setMode(nextMode);
setIsOpen(true);
setError(null);
setHistoricalError(null);
setWikiError(null);
};
return (
<div
style={{
position: "absolute",
top: 10,
left: "50%",
transform: "translateX(-50%)",
zIndex: 18,
width: "min(392px, calc(100vw - 36px))",
pointerEvents: "auto",
...style,
}}
onMouseDown={(event) => event.stopPropagation()}
>
<div style={searchCardStyle}>
<div style={searchInputRowStyle}>
<button
type="button"
onClick={cycleMode}
title={`Đổi chế độ tìm kiếm (hiện tại: ${getSearchModeLabel(mode)})`}
aria-label={`Đổi chế độ tìm kiếm (hiện tại: ${getSearchModeLabel(mode)})`}
style={modeSwitchStyle}
>
{getSearchModeLabel(mode)}
</button>
<input
value={activeQuery}
onChange={(event) => {
if (mode === "present") {
setQuery(event.target.value);
} else if (mode === "history") {
setHistoricalQuery(event.target.value);
} else {
setWikiQuery(event.target.value);
}
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setIsOpen(false);
return;
}
if (event.key === "Enter") {
if (mode === "present" && results[0]) {
event.preventDefault();
void selectPrediction(results[0]);
}
if (mode === "history" && historicalResults[0]) {
event.preventDefault();
selectHistoricalEntity(historicalResults[0]);
}
if (mode === "wiki" && wikiResults[0]) {
event.preventDefault();
selectWiki(wikiResults[0]);
}
}
}}
disabled={mode === "present" && !hasApiKey}
placeholder={
mode === "present"
? "Tìm địa điểm hiện tại"
: mode === "history"
? "Tìm thực thể lịch sử"
: "Tìm bài viết wiki"
}
style={inputStyle}
/>
{activeQuery || focusedPlace ? (
<button
type="button"
onClick={clearSearch}
title="Xóa tìm kiếm"
aria-label="Xóa ô tìm kiếm"
style={clearButtonStyle}
>
x
</button>
) : null}
</div>
</div>
{isOpen && shouldRenderResults(mode, activeQuery, activeLoading, activeError, results, historicalResults, wikiResults) ? (
<div style={resultsPanelStyle}>
{mode === "present" ? (
<PresentResults
isLoading={isLoading}
error={error}
query={query}
results={results}
selectingPlaceId={selectingPlaceId}
onSelect={selectPrediction}
/>
) : mode === "history" ? (
<HistoricalResults
isLoading={isHistoricalLoading}
error={historicalError}
query={historicalQuery}
results={historicalResults}
expandedEntityId={expandedEntityId}
adminLabels={adminLabels}
selectingGeometryId={selectingGeometryId}
hasApiKey={hasApiKey}
onSelectEntity={selectHistoricalEntity}
onSelectGeometry={selectHistoricalGeometry}
/>
) : (
<WikiResults
isLoading={isWikiLoading}
error={wikiError}
query={wikiQuery}
results={wikiResults}
onSelect={selectWiki}
/>
)}
</div>
) : null}
</div>
);
}
function PresentResults({
isLoading,
error,
query,
results,
selectingPlaceId,
onSelect,
}: {
isLoading: boolean;
error: string | null;
query: string;
results: PresentPlacePrediction[];
selectingPlaceId: string | null;
onSelect: (prediction: PresentPlacePrediction) => Promise<void>;
}) {
if (isLoading) return <div style={statusStyle}>Đang tìm...</div>;
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không kết quả.</div>;
return (
<>
{results.map((result) => (
<button
key={result.placeId}
type="button"
onClick={() => void onSelect(result)}
disabled={selectingPlaceId === result.placeId}
style={{
...resultButtonStyle,
cursor: selectingPlaceId === result.placeId ? "wait" : "pointer",
}}
onMouseEnter={(event) => {
event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = "transparent";
}}
>
<span style={primaryResultTextStyle}>{result.mainText}</span>
<span style={secondaryResultTextStyle}>{result.secondaryText || result.description}</span>
</button>
))}
</>
);
}
function HistoricalResults({
isLoading,
error,
query,
results,
expandedEntityId,
adminLabels,
selectingGeometryId,
hasApiKey,
onSelectEntity,
onSelectGeometry,
}: {
isLoading: boolean;
error: string | null;
query: string;
results: EntityGeometriesSearchItem[];
expandedEntityId: string | null;
adminLabels: Record<string, AdminLabelState>;
selectingGeometryId: string | null;
hasApiKey: boolean;
onSelectEntity: (item: EntityGeometriesSearchItem) => void;
onSelectGeometry: (item: EntityGeometriesSearchItem, geometry: EntityGeometrySearchGeo) => void;
}) {
if (isLoading) return <div style={statusStyle}>Đang tìm thực thể...</div>;
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không thực thể phù hợp.</div>;
return (
<>
{results.map((item) => {
const isExpanded = expandedEntityId === item.entity_id;
return (
<div key={item.entity_id} style={{ borderBottom: "1px solid rgba(148, 163, 184, 0.12)" }}>
<button
type="button"
onClick={() => onSelectEntity(item)}
disabled={!item.geometries.length}
style={{
...resultButtonStyle,
cursor: item.geometries.length ? "pointer" : "not-allowed",
}}
onMouseEnter={(event) => {
if (item.geometries.length) event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = "transparent";
}}
>
<span style={primaryResultTextStyle}>{item.name || item.entity_id}</span>
<span style={secondaryResultTextStyle}>
{item.geometries.length
? `${item.geometries.length} hình bản đồ`
: "Không có hình bản đồ"}
{item.description ? ` · ${item.description}` : ""}
</span>
</button>
{isExpanded ? (
<div style={{ padding: "0 8px 8px", display: "grid", gap: 6 }}>
{!hasApiKey ? (
<div style={{ ...statusStyle, padding: "7px 8px" }}>
Thiếu SEARCH_MAP_API_KEY đ lấy đa danh hiện tại.
</div>
) : null}
{item.geometries.map((geometry) => (
<button
key={geometry.id}
type="button"
onClick={() => onSelectGeometry(item, geometry)}
disabled={selectingGeometryId === geometry.id}
style={{
border: "1px solid rgba(148, 163, 184, 0.16)",
borderRadius: 8,
background: "rgba(15, 23, 42, 0.68)",
color: "#e2e8f0",
padding: "8px 9px",
textAlign: "left",
cursor: selectingGeometryId === geometry.id ? "wait" : "pointer",
}}
>
<span style={{ ...primaryResultTextStyle, fontSize: 12 }}>
{formatAdminLabel(adminLabels[geometry.id])}
</span>
<span style={{ ...secondaryResultTextStyle, marginTop: 3 }}>
{formatGeometryMeta(geometry)}
</span>
</button>
))}
</div>
) : null}
</div>
);
})}
</>
);
}
function WikiResults({
isLoading,
error,
query,
results,
onSelect,
}: {
isLoading: boolean;
error: string | null;
query: string;
results: Wiki[];
onSelect: (wiki: Wiki) => void;
}) {
if (isLoading) return <div style={statusStyle}>Đang tìm wiki...</div>;
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không tìm thấy bài viết.</div>;
return (
<>
{results.map((wiki) => (
<button
key={wiki.id}
type="button"
onClick={() => onSelect(wiki)}
style={resultButtonStyle}
onMouseEnter={(event) => {
event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = "transparent";
}}
>
<span style={primaryResultTextStyle}>{wiki.title || wiki.slug || "Không có tiêu đề"}</span>
{wiki.slug && (
<span style={secondaryResultTextStyle}>/wiki/{wiki.slug}</span>
)}
</button>
))}
</>
);
}
function shouldRenderResults(
mode: SearchMode,
query: string,
isLoading: boolean,
error: string | null,
presentResults: PresentPlacePrediction[],
historicalResults: EntityGeometriesSearchItem[],
wikiResults: Wiki[]
): boolean {
if (isLoading || error || query.trim().length >= 2) return true;
if (mode === "present") return presentResults.length > 0;
if (mode === "history") return historicalResults.length > 0;
return wikiResults.length > 0;
}
function formatAdminLabel(state: AdminLabelState | undefined): string {
if (!state || state.status === "loading") return "Đang lấy địa danh hiện tại...";
if (state.status === "error") return "Không lấy được địa danh hiện tại";
return state.label || state.address || "Địa danh hiện tại không rõ";
}
function formatGeometryMeta(geometry: EntityGeometrySearchGeo): string {
const type = geometry.type || "hình bản đồ";
const timeStart = geometry.time_start ?? null;
const timeEnd = geometry.time_end ?? null;
const time =
timeStart !== null && timeEnd !== null
? `${timeStart} - ${timeEnd}`
: timeStart !== null
? `từ ${timeStart}`
: timeEnd !== null
? `đến ${timeEnd}`
: "không rõ thời gian";
return `${type} · ${time}`;
}
const searchCardStyle = {
border: "1px solid rgba(148, 163, 184, 0.28)",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.92)",
boxShadow: "0 16px 36px rgba(2, 6, 23, 0.35)",
color: "#e2e8f0",
overflow: "hidden",
backdropFilter: "blur(6px)",
} satisfies CSSProperties;
const searchInputRowStyle = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "9px 10px",
} satisfies CSSProperties;
const inputStyle = {
flex: 1,
minWidth: 0,
border: "none",
outline: "none",
background: "transparent",
color: "#f8fafc",
fontSize: 13,
fontWeight: 700,
} satisfies CSSProperties;
const modeSwitchStyle = {
border: "none",
borderRight: "1px solid rgba(148, 163, 184, 0.22)",
background: "transparent",
color: "#38bdf8",
padding: "0 10px 0 0",
fontSize: 11,
fontWeight: 900,
cursor: "pointer",
lineHeight: 1,
} satisfies CSSProperties;
const clearButtonStyle = {
width: 26,
height: 26,
border: "1px solid rgba(148, 163, 184, 0.28)",
borderRadius: 6,
background: "rgba(15, 23, 42, 0.74)",
color: "#cbd5e1",
cursor: "pointer",
fontWeight: 900,
} satisfies CSSProperties;
const resultsPanelStyle = {
marginTop: 8,
overflow: "hidden",
border: "1px solid rgba(148, 163, 184, 0.24)",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.96)",
boxShadow: "0 16px 36px rgba(2, 6, 23, 0.4)",
color: "#e2e8f0",
backdropFilter: "blur(6px)",
} satisfies CSSProperties;
const resultButtonStyle = {
display: "grid",
gap: 3,
width: "100%",
padding: "10px 12px",
border: "none",
background: "transparent",
color: "#e2e8f0",
textAlign: "left",
} satisfies CSSProperties;
const primaryResultTextStyle = {
display: "block",
fontSize: 13,
fontWeight: 900,
overflowWrap: "anywhere",
} satisfies CSSProperties;
const secondaryResultTextStyle = {
display: "block",
fontSize: 11,
color: "#94a3b8",
lineHeight: 1.35,
overflowWrap: "anywhere",
} satisfies CSSProperties;
const statusStyle = {
padding: "11px 12px",
color: "#94a3b8",
fontSize: 12,
fontWeight: 700,
} satisfies CSSProperties;
function getSearchModeLabel(mode: SearchMode): string {
if (mode === "present") return "Hiện tại";
if (mode === "history") return "Lịch sử";
return "Wiki";
}
@@ -0,0 +1,677 @@
"use client";
import { useMemo, useState, memo, type CSSProperties } from "react";
import type { EntitySnapshot } from "@/uhm/types/entities";
import { useShallow } from "zustand/react/shallow";
import NewBadge from "@/uhm/components/editor/NewBadge";
import { useEditorStore } from "@/uhm/store/editorStore";
import { newId } from "@/uhm/lib/utils/id";
type Props = {
onCreateEntityOnly: () => void;
onUpdateEntity?: (entityId: string, payload: { name: string; description: string | null; time_start: string; time_end: string }) => void;
hasSelectedGeometry?: boolean;
selectedGeometryTime?: { time_start: number | null; time_end: number | null } | null;
onToggleBindEntityForSelectedGeometry?: (entityId: string, nextChecked: boolean) => void;
onRerollEntityId?: (oldId: string, nextId: string) => void;
onDeleteEntity?: (entityId: string) => void;
};
function ProjectEntityRefsPanel({
onCreateEntityOnly,
onUpdateEntity,
hasSelectedGeometry,
selectedGeometryTime,
onToggleBindEntityForSelectedGeometry,
onRerollEntityId,
onDeleteEntity,
}: Props) {
const {
snapshotEntityRows,
entityForm,
setEntityForm,
isEntitySubmitting,
entityFormStatus,
selectedGeometryEntityIds,
} = useEditorStore(
useShallow((state) => ({
snapshotEntityRows: state.snapshotEntityRows,
entityForm: state.entityForm,
setEntityForm: state.setEntityForm,
isEntitySubmitting: state.isEntitySubmitting,
entityFormStatus: state.entityFormStatus,
selectedGeometryEntityIds: state.selectedGeometryEntityIds,
}))
);
const canBindToggle =
Boolean(hasSelectedGeometry) &&
Array.isArray(selectedGeometryEntityIds) &&
typeof onToggleBindEntityForSelectedGeometry === "function";
const canEditEntity = typeof onUpdateEntity === "function";
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const selectedEntityIdSet = useMemo(
() => new Set((selectedGeometryEntityIds || []).map(String)),
[selectedGeometryEntityIds]
);
const entityRefs = useMemo(() => {
const byId = new globalThis.Map<string, EntitySnapshot>();
for (const ref of snapshotEntityRows || []) {
const id = String(ref?.id || "").trim();
if (!id || byId.has(id)) continue;
if (ref.operation === "delete") continue;
byId.set(id, ref);
}
return Array.from(byId.values());
}, [snapshotEntityRows]);
const sortedEntityRefs = useMemo(() => {
const rows = [...(entityRefs || [])];
rows.sort((a, b) => {
const aBound = selectedEntityIdSet.has(String(a.id));
const bBound = selectedEntityIdSet.has(String(b.id));
if (aBound !== bBound) return aBound ? -1 : 1;
const aLabel = String(a.name || a.id || "");
const bLabel = String(b.name || b.id || "");
return aLabel.localeCompare(bLabel);
});
return rows;
}, [entityRefs, selectedEntityIdSet]);
const activeEntity = useMemo(
() => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null),
[activeEntityId, entityRefs]
);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editTimeStart, setEditTimeStart] = useState("");
const [editTimeEnd, setEditTimeEnd] = useState("");
const canCopySelectedGeometryTime =
selectedGeometryTime != null &&
(selectedGeometryTime.time_start != null || selectedGeometryTime.time_end != null);
const openEntityEditor = (entity: EntitySnapshot) => {
setActiveEntityId(String(entity.id));
setEditName(typeof entity.name === "string" ? entity.name : "");
setEditDescription(entity.description == null ? "" : String(entity.description));
setEditTimeStart(entity.time_start != null ? String(entity.time_start) : "");
setEditTimeEnd(entity.time_end != null ? String(entity.time_end) : "");
};
const handleEntityFormChange = (key: "name" | "description" | "time_start" | "time_end", value: string) => {
setEntityForm((prev) => ({ ...prev, [key]: value }));
};
const copySelectedGeometryTimeToCreateForm = () => {
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
setEntityForm((prev) => ({
...prev,
time_start: selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "",
time_end: selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "",
}));
};
const copySelectedGeometryTimeToEditForm = () => {
if (!canCopySelectedGeometryTime || !selectedGeometryTime) return;
setEditTimeStart(selectedGeometryTime.time_start != null ? String(selectedGeometryTime.time_start) : "");
setEditTimeEnd(selectedGeometryTime.time_end != null ? String(selectedGeometryTime.time_end) : "");
};
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entities</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{entityRefs.length}</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? "Mo panel" : "Thu gon panel"}
aria-label={collapsed ? "Mo panel Entities" : "Thu gon panel Entities"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{collapsed ? <PlusIcon /> : <MinusIcon />}
</button>
</div>
</div>
{collapsed ? null : sortedEntityRefs.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{sortedEntityRefs.map((e) => {
const entityId = String(e.id);
const isBoundToSelectedGeometry = selectedEntityIdSet.has(entityId);
const isActive = activeEntityId === entityId;
return (
<div
key={e.id}
style={{
padding: "8px",
borderRadius: "6px",
border: isActive
? "1px solid #2563eb"
: isBoundToSelectedGeometry
? "1px solid rgba(20, 184, 166, 0.65)"
: "1px solid #1f2937",
background: isBoundToSelectedGeometry ? "rgba(20, 184, 166, 0.12)" : "transparent",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<button
type="button"
onClick={() => openEntityEditor(e)}
title="Chon de sua"
style={{
flex: 1,
minWidth: 0,
textAlign: "left",
border: "none",
background: "transparent",
padding: 0,
cursor: canEditEntity ? "pointer" : "default",
}}
disabled={!canEditEntity}
>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.name || e.id}
</span>
{isBoundToSelectedGeometry ? <span style={boundBadgeStyle}>bound</span> : null}
{isNewEntityRef(e) ? <NewBadge /> : null}
</div>
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.id}
</div>
</button>
{canBindToggle ? (
<button
type="button"
title={isBoundToSelectedGeometry ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={() =>
onToggleBindEntityForSelectedGeometry!(
entityId,
!isBoundToSelectedGeometry
)
}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}}
aria-label={
isBoundToSelectedGeometry
? `Unbind entity ${entityId} from selected geometry`
: `Bind entity ${entityId} to selected geometry`
}
>
{isBoundToSelectedGeometry ? (
<UnlockIcon />
) : (
<LockIcon />
)}
</button>
) : null}
{typeof onDeleteEntity === "function" ? (
<button
type="button"
title="Xóa thực thể khỏi dự án"
onClick={() => onDeleteEntity(entityId)}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
cursor: "pointer",
flex: "0 0 auto",
}}
aria-label={`Xóa thực thể ${entityId}`}
>
<TrashIcon />
</button>
) : null}
</div>
);
})}
</div>
) : (
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
)}
{collapsed ? null : canEditEntity && activeEntity ? (
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #0f766e",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
<span style={{ color: "#a7f3d0", fontWeight: 700, fontSize: "12px" }}>
Sua entity
</span>
{isNewEntityRef(activeEntity) ? <NewBadge /> : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<button
type="button"
onClick={copySelectedGeometryTimeToEditForm}
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
title="Lay nam cua GEO dang chon"
aria-label="Lay nam cua GEO dang chon cho entity dang sua"
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
>
<ClockIcon />
</button>
<button
type="button"
onClick={() => setActiveEntityId(null)}
title="Dong"
aria-label="Dong sua entity"
style={iconButtonStyle(false)}
>
<CloseIcon />
</button>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontSize: 11, color: "#94a3b8", overflowWrap: "anywhere", minWidth: 0, flex: 1 }}>
{String(activeEntity.id)}
</div>
{activeEntity.source === "inline" && onRerollEntityId && (
<button
type="button"
onClick={() => {
const nextId = newId();
onRerollEntityId(String(activeEntity.id), nextId);
setActiveEntityId(nextId);
}}
disabled={isEntitySubmitting}
title="Đổi mã ID để sinh ngẫu nhiên màu sắc mới cho thực thể này"
style={{
border: "1px solid #0f766e",
borderRadius: "4px",
padding: "2px 6px",
background: "transparent",
color: "#14b8a6",
fontSize: "11px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
flex: "0 0 auto",
}}
>
Đi màu (Reroll ID)
</button>
)}
</div>
<input
value={editName}
onChange={(event) => setEditName(event.target.value)}
placeholder="Ten entity"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
placeholder="Description"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={timeInputGridStyle}>
<input
value={editTimeStart}
onChange={(event) => setEditTimeStart(event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={editTimeEnd}
onChange={(event) => setEditTimeEnd(event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<div style={{ display: "flex", gap: "8px" }}>
<button
type="button"
onClick={() => onUpdateEntity!(String(activeEntity.id), {
name: editName,
description: editDescription.trim().length ? editDescription : null,
time_start: editTimeStart,
time_end: editTimeEnd,
})}
disabled={isEntitySubmitting}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Luu entity
</button>
{typeof onDeleteEntity === "function" && (
<button
type="button"
onClick={() => {
onDeleteEntity(String(activeEntity.id));
setActiveEntityId(null);
}}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#7f1d1d",
color: "#fecaca",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Xóa
</button>
)}
</div>
</div>
) : null}
{collapsed ? null : (
<>
<div
style={{
marginTop: "10px",
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo entity mới
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{isCreateOpen ? (
<button
type="button"
onClick={copySelectedGeometryTimeToCreateForm}
disabled={!canCopySelectedGeometryTime || isEntitySubmitting}
title="Lay nam cua GEO dang chon"
aria-label="Lay nam cua GEO dang chon cho entity moi"
style={iconButtonStyle(!canCopySelectedGeometryTime || isEntitySubmitting)}
>
<ClockIcon />
</button>
) : null}
<button
type="button"
onClick={() => setIsCreateOpen((v) => !v)}
disabled={isEntitySubmitting}
title={isCreateOpen ? "Dong" : "Mo"}
aria-label={isCreateOpen ? "Dong tao entity" : "Mo tao entity"}
style={iconButtonStyle(isEntitySubmitting)}
>
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
</button>
</div>
</div>
{isCreateOpen ? (
<>
<input
value={entityForm.name}
onChange={(event) => handleEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.description}
onChange={(event) => handleEntityFormChange("description", event.target.value)}
placeholder="Description"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={timeInputGridStyle}>
<input
value={entityForm.time_start}
onChange={(event) => handleEntityFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.time_end}
onChange={(event) => handleEntityFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<button
type="button"
onClick={onCreateEntityOnly}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Tạo entity mới
</button>
</>
) : null}
</div>
{entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
{entityFormStatus}
</div>
) : null}
</>
)}
</div>
);
}
function isNewEntityRef(entity: EntitySnapshot | null | undefined): boolean {
return entity?.source === "inline" && entity?.operation === "create";
}
const entityInputStyle: CSSProperties = {
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
};
const timeInputGridStyle: CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
};
function iconButtonStyle(disabled: boolean): CSSProperties {
return {
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
};
}
const boundBadgeStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
height: 17,
padding: "0 6px",
borderRadius: 999,
border: "1px solid rgba(45, 212, 191, 0.5)",
background: "rgba(20, 184, 166, 0.18)",
color: "#99f6e4",
fontSize: 10,
fontWeight: 900,
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: 0,
};
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M7 10V8a5 5 0 0 1 10 0v2"
stroke="#cbd5e1"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#cbd5e1"
strokeWidth="2"
/>
</svg>
);
}
function UnlockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M17 10V8a5 5 0 0 0-9.5-2"
stroke="#a7f3d0"
strokeWidth="2"
strokeLinecap="round"
/>
<rect
x="6"
y="10"
width="12"
height="10"
rx="2"
stroke="#a7f3d0"
strokeWidth="2"
/>
</svg>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function ClockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="8" stroke="#fbbf24" strokeWidth="2" />
<path d="M12 7v5l3 2" stroke="#fbbf24" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function TrashIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M19 7l-.867 12.142A2 2 0 0 1 16.138 21H7.862a2 2 0 0 1-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16"
stroke="#f87171"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default memo(ProjectEntityRefsPanel);
@@ -0,0 +1,36 @@
import { Panel } from "./Panel";
type ProjectPanelProps = {
sectionTitle: string;
projectStatus: string;
commitCount: number;
latestCommitLabel: string | null;
};
export function ProjectPanel({
sectionTitle,
projectStatus,
commitCount,
latestCommitLabel,
}: ProjectPanelProps) {
return (
<Panel title="Project" defaultOpen>
<div style={{ fontSize: 12, color: "#cbd5e1", lineHeight: 1.4 }}>
<div style={{ color: "white", fontWeight: 850, overflowWrap: "anywhere" }}>{sectionTitle}</div>
<div style={{ marginTop: 6 }}>
Status: <span style={{ color: "#e2e8f0" }}>{projectStatus}</span>
</div>
<div style={{ marginTop: 6 }}>
Commits: <span style={{ color: "#e2e8f0" }}>{commitCount}</span>
</div>
<div style={{ marginTop: 6 }}>
{latestCommitLabel ? (
<span style={{ color: "#e2e8f0" }}>{latestCommitLabel}</span>
) : (
<span style={{ color: "#94a3b8" }}>Chưa head commit</span>
)}
</div>
</div>
</Panel>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,404 @@
"use client";
import type { BackgroundLayerId } from "@/uhm/lib/map/styles/backgroundLayers";
import { BACKGROUND_LAYER_OPTIONS } from "@/uhm/lib/map/styles/backgroundLayers";
type Props = {
backgroundVisibility: Record<string, boolean>;
geometryVisibility: Record<string, boolean>;
onToggleBackground: (id: BackgroundLayerId) => void;
onToggleGeometry: (typeKey: string) => void;
onHide?: () => void;
};
// Map each layer ID/geometry type to a premium inline SVG icon
const LAYER_ICONS: Record<string, React.ReactNode> = {
// Background layers
"raster-base-layer": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
),
"bg-country-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12h18M3 6h18M3 18h18" strokeDasharray="2 2" />
<rect x="2" y="2" width="20" height="20" rx="4" />
</svg>
),
"bg-province-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" strokeDasharray="3 3" />
<rect x="2" y="2" width="20" height="20" rx="3" />
</svg>
),
"bg-district-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3v18M12 3v18M18 3v18M3 6h18M3 12h18M3 18h18" strokeDasharray="1 3" />
<rect x="2" y="2" width="20" height="20" rx="2" />
</svg>
),
"country-labels": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7V4h16v3M9 20h6M12 4v16" />
</svg>
),
"rivers-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12c3-3 6-3 9 0s6 3 9 0" />
<path d="M3 16c3-3 6-3 9 0s6 3 9 0" opacity="0.6" />
</svg>
),
// Polygon Geometries
country: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5" />
</svg>
),
state: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 4 20 9 20 15 12 20 4 15 4 9" />
</svg>
),
faction: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7" />
</svg>
),
rebellion_zone: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
</svg>
),
// Line Geometries
defense_line: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="10" rx="2" />
<path d="M12 2v9M8 5v3M16 5v3" />
</svg>
),
military_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 17.5L3 6M17.5 14.5L6 3" />
<path d="M12 12l9 9M18 15h3v3" />
</svg>
),
retreat_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
),
migration_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
trade_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8M12 6v12" />
</svg>
),
// Point Geometries
battle: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 17.5L3 6M17.5 14.5L6 3" />
<path d="M8.5 19.5L19.5 8.5" />
</svg>
),
person_event: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
temple: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7h20L12 2zM4 7v10h16V7H4zm2 10v4h2v-4H6zm10 0v4h2v-4h-2z" />
</svg>
),
capital: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polygon points="12 7 13.9 10.8 18.1 11.4 15.1 14.4 15.8 18.6 12 16.6 8.2 18.6 8.9 14.4 5.9 11.4 10.1 10.8" fill="currentColor" />
</svg>
),
city: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
<line x1="9" y1="22" x2="9" y2="16" />
<line x1="15" y1="22" x2="15" y2="16" />
<line x1="9" y1="16" x2="15" y2="16" />
<path d="M8 6h2M14 6h2M8 10h2M14 10h2" strokeWidth="1.5" />
</svg>
),
fortification: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 22V8l3-3h10l3 3v14H4z" />
<path d="M9 22v-6h6v6H9z" />
<path d="M8 8h8M12 5v3" />
</svg>
),
ruin: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="6" y1="20" x2="6" y2="4" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="3" y1="4" x2="9" y2="4" />
<line x1="15" y1="4" x2="21" y2="4" />
<line x1="3" y1="20" x2="21" y2="20" />
<line x1="6" y1="12" x2="18" y2="12" strokeDasharray="3 3" />
</svg>
),
port: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="3" />
<line x1="12" y1="22" x2="12" y2="8" />
<path d="M5 12h14M12 12c-4 0-6 4-6 6a6 6 0 0 0 12 0c0-2-2-6-6-6z" />
</svg>
),
};
export default function ReplayPreviewLayerPanel({
backgroundVisibility,
geometryVisibility,
onToggleBackground,
onToggleGeometry,
onHide,
}: Props) {
// Categorize geometry types for logical grouping
const polygonKeys = ["country", "state", "faction", "rebellion_zone"];
const lineKeys = ["defense_line", "military_route", "retreat_route", "migration_route", "trade_route"];
const pointKeys = ["battle", "person_event", "temple", "capital", "city", "fortification", "ruin", "port"];
const getButtonStyles = (isActive: boolean, activeColor: string): React.CSSProperties => ({
border: "none",
background: isActive ? `rgba(${activeColor}, 0.18)` : "rgba(30, 41, 59, 0.4)",
color: isActive ? `rgb(${activeColor})` : "#64748b",
width: 36,
height: 36,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
boxShadow: isActive ? `inset 0 0 0 1px rgba(${activeColor}, 0.3), 0 0 12px rgba(${activeColor}, 0.2)` : "inset 0 0 0 1px rgba(148, 163, 184, 0.1)",
outline: "none",
});
const renderStyles = () => (
<style dangerouslySetInnerHTML={{ __html: `
.replay-preview-layer-panel-scroll::-webkit-scrollbar {
display: none;
}
.replay-preview-layer-panel-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
`}} />
);
return (
<div
className="replay-preview-layer-panel"
style={{
display: "flex",
flexDirection: "column",
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.8))",
border: "1px solid rgba(148, 163, 184, 0.22)",
borderRadius: 20,
padding: "14px 10px",
width: 58,
boxSizing: "border-box",
alignItems: "center",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)",
maxHeight: "100%",
overflowX: "hidden",
overflowY: "hidden",
}}
>
{renderStyles()}
<div
className="replay-preview-layer-panel-scroll"
style={{
flexGrow: 1,
overflowY: "auto",
overflowX: "hidden",
width: "100%",
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
}}
>
{/* Background layers */}
<div style={groupHeaderStyle}>Bản đ</div>
<div style={gridStyle}>
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
const active = Boolean(backgroundVisibility[layer.id]);
return (
<button
key={layer.id}
type="button"
title={layer.label}
onClick={() => onToggleBackground(layer.id)}
style={getButtonStyles(active, "56, 189, 248")} // sky-400
>
{LAYER_ICONS[layer.id] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Territories / Polygons */}
<div style={groupHeaderStyle}>Khu vực</div>
<div style={gridStyle}>
{polygonKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = getGeometryTypeLabel(typeKey);
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "249, 115, 22")} // orange-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Routes / Lines */}
<div style={groupHeaderStyle}>Tuyến</div>
<div style={gridStyle}>
{lineKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = getGeometryTypeLabel(typeKey);
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "192, 132, 252")} // purple-400
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Places & Events / Points */}
<div style={groupHeaderStyle}>Điểm</div>
<div style={gridStyle}>
{pointKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = getGeometryTypeLabel(typeKey);
return (
<button
key={typeKey}
type="button"
title={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "245, 158, 11")} // amber-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
</div>
{onHide && (
<div
style={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
marginTop: 6,
flexShrink: 0,
}}
>
<div style={dividerStyle} />
<button
type="button"
title="Ẩn bảng lớp bản đồ"
onClick={onHide}
style={getButtonStyles(true, "239, 68, 68")}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
</button>
</div>
)}
</div>
);
}
const groupHeaderStyle: React.CSSProperties = {
fontSize: 9,
fontWeight: 900,
color: "#94a3b8",
letterSpacing: 1,
textTransform: "uppercase",
width: "100%",
textAlign: "center",
marginBottom: 4,
};
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr",
gap: 8,
width: "100%",
};
const dividerStyle: React.CSSProperties = {
height: 1,
background: "rgba(148, 163, 184, 0.15)",
width: "80%",
margin: "6px 0",
};
function getGeometryTypeLabel(typeKey: string): string {
const labels: Record<string, string> = {
country: "Quốc gia",
state: "Nhà nước / vùng",
faction: "Phe phái",
rebellion_zone: "Vùng nổi dậy",
defense_line: "Tuyến phòng thủ",
military_route: "Đường hành quân",
retreat_route: "Đường rút lui",
migration_route: "Đường di cư",
trade_route: "Tuyến thương mại",
battle: "Trận đánh",
person_event: "Nhân vật / sự kiện",
temple: "Đền miếu",
capital: "Kinh đô",
city: "Thành phố",
fortification: "Công sự",
ruin: "Di tích",
port: "Cảng",
};
return labels[typeKey] || typeKey.replaceAll("_", " ");
}
@@ -0,0 +1,314 @@
"use client";
import type { CSSProperties } from "react";
import type { DialogState } from "@/uhm/types/projects";
import type { ReplayPreviewToast } from "@/uhm/lib/replay/useReplayPreview";
type Props = {
isPreviewMode: boolean;
isPlaying: boolean;
dialog: DialogState | null;
toasts: ReplayPreviewToast[];
sidebarOpen: boolean;
sidebarWidth?: number;
playbackSpeed: number;
activeStepLabel: string | null;
activeStepNumber: number | null;
totalSteps: number;
playButtonLabel?: string;
onPlayPreview: () => void;
onStopPreview: () => void;
onResetPreview: () => void;
onExitPreview: () => void;
};
export default function ReplayPreviewOverlay({
isPreviewMode,
isPlaying,
dialog,
toasts,
sidebarOpen,
sidebarWidth = 420,
playbackSpeed,
activeStepLabel,
activeStepNumber,
totalSteps,
playButtonLabel = "Phát lại",
onPlayPreview,
onStopPreview,
onResetPreview,
onExitPreview,
}: Props) {
const hasWikiPreview = sidebarOpen;
const rightOffset = hasWikiPreview ? sidebarWidth + 32 : 18;
const shouldRender =
isPreviewMode ||
isPlaying ||
Boolean(dialog) ||
Boolean(toasts.length);
if (!shouldRender) {
return null;
}
return (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 60,
pointerEvents: isPreviewMode ? "auto" : "none",
}}
>
{toasts.length ? (
<div
style={{
position: "absolute",
top: 72,
right: rightOffset,
display: "grid",
gap: 8,
width: 280,
}}
>
{toasts.map((toast) => (
<div
key={toast.id}
style={{
borderRadius: 14,
border: "1px solid rgba(56, 189, 248, 0.28)",
background: "rgba(8, 47, 73, 0.9)",
color: "#e0f2fe",
padding: "12px 14px",
fontSize: 13,
lineHeight: 1.4,
boxShadow: "0 10px 26px rgba(2, 6, 23, 0.32)",
}}
>
{toast.message}
</div>
))}
</div>
) : null}
{dialog && (dialog.text?.trim() || dialog.image_url?.trim()) ? (
<div
style={{
position: "absolute",
left: 88,
right: rightOffset,
bottom: 96,
pointerEvents: "none",
display: "flex",
justifyContent: "flex-start",
}}
>
<div
style={{
width: "min(640px, 100%)",
borderRadius: 20,
overflow: "hidden",
border: "1px solid rgba(255, 255, 255, 0.1)",
background: "rgba(11, 18, 32, 0.85)",
backdropFilter: "blur(12px)",
boxShadow: "0 16px 44px rgba(2, 6, 23, 0.42)",
pointerEvents: "auto",
display: "flex",
flexDirection: "column",
maxHeight: "calc(100vh - 180px)",
}}
>
{dialog.image_url?.trim() ? (
<img
src={dialog.image_url}
alt="Hình ảnh lịch sử"
style={{
width: "100%",
display: "block",
maxHeight: 140,
objectFit: "cover",
background: "#020617",
}}
/>
) : null}
{dialog.text?.trim() ? (
<div
className="uhm-replay-dialog-content"
style={{
padding: "16px",
color: "#f8fafc",
fontSize: "14px",
lineHeight: "1.6",
overflowY: "auto",
maxHeight: dialog.image_url?.trim() ? "180px" : "140px",
minHeight: 0,
background: "transparent",
}}
dangerouslySetInnerHTML={{ __html: dialog.text }}
/>
) : null}
</div>
</div>
) : null}
<style jsx>{`
.uhm-replay-dialog-content::-webkit-scrollbar {
display: none;
}
.uhm-replay-dialog-content {
scrollbar-width: none;
-ms-overflow-style: none;
}
.uhm-replay-dialog-content :global(p) {
margin: 0;
}
.uhm-replay-dialog-content :global(p + p) {
margin-top: 6px;
}
.uhm-replay-dialog-content :global(ul),
.uhm-replay-dialog-content :global(ol) {
margin: 0;
padding-left: 20px;
}
`}</style>
{isPreviewMode ? (
<div
style={{
position: "absolute",
top: 64,
left: "50%",
transform: "translateX(-50%)",
width: "min(520px, calc(100% - 72px))",
borderRadius: 18,
border: "1px solid rgba(148, 163, 184, 0.24)",
background: "rgba(15, 23, 42, 0.9)",
color: "#e2e8f0",
padding: "12px 14px",
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.3)",
pointerEvents: "auto",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div style={{ display: "grid", gap: 6, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span
style={{
display: "inline-flex",
alignItems: "center",
padding: "3px 8px",
borderRadius: 999,
background: "rgba(34, 197, 94, 0.2)",
color: "#86efac",
fontWeight: 900,
fontSize: 11,
letterSpacing: 0.3,
textTransform: "uppercase",
}}
>
Xem trước
</span>
{activeStepLabel ? (
<span
style={{
fontSize: 13,
fontWeight: 800,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{activeStepLabel}
</span>
) : null}
<span style={{ fontSize: 12, color: "#94a3b8" }}>
x{playbackSpeed.toFixed(2)}
</span>
</div>
{totalSteps > 0 ? (
<div style={{ display: "grid", gap: 6 }}>
<div
style={{
width: "100%",
height: 6,
borderRadius: 999,
background: "rgba(51, 65, 85, 0.8)",
overflow: "hidden",
}}
>
<div
style={{
width: `${Math.max(0, Math.min(100, ((activeStepNumber || 0) / totalSteps) * 100))}%`,
height: "100%",
borderRadius: 999,
background: "linear-gradient(90deg, #22c55e, #38bdf8)",
transition: "width 180ms ease",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#94a3b8" }}>
Bước {activeStepNumber || 0}/{totalSteps}
</div>
</div>
) : null}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }}>
{isPlaying ? (
<>
<button
type="button"
onClick={onStopPreview}
style={previewButtonStyle("#7f1d1d")}
>
Dừng
</button>
<button
type="button"
onClick={onResetPreview}
style={previewButtonStyle("#1e3a8a")}
>
Phát lại từ đu
</button>
</>
) : (
<button
type="button"
onClick={onPlayPreview}
style={previewButtonStyle("#166534")}
>
{playButtonLabel}
</button>
)}
<button
type="button"
onClick={onExitPreview}
style={previewButtonStyle("#334155")}
>
Thoát xem trước
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function previewButtonStyle(background: string): CSSProperties {
return {
border: "none",
background,
color: "white",
borderRadius: 10,
padding: "8px 12px",
cursor: "pointer",
fontSize: 12,
fontWeight: 800,
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08)",
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,470 @@
"use client";
import { type CSSProperties, memo, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { Feature } from "@/uhm/lib/editor/state/useEditorState";
import {
GEOMETRY_TYPE_OPTIONS,
GeometryPreset,
GeometryTypeGroupId,
findGeometryTypeOption,
groupGeometryTypeOptions,
} from "@/uhm/lib/map/geo/geometryTypeOptions";
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
import { useEditorStore } from "@/uhm/store/editorStore";
type Props = {
selectedFeatures: Feature[];
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
changeCount: number;
onReplayEdit?: (id: string | number) => void;
onDeleteFeatures?: (ids: (string | number)[]) => void;
onDeselectAll?: () => void;
onRerollGeometryId?: (oldId: string | number) => void;
};
function SelectedGeometryPanel({
selectedFeatures,
onApplyGeometryMetadata,
changeCount,
onReplayEdit,
onDeleteFeatures,
onDeselectAll,
onRerollGeometryId,
}: Props) {
const {
geometryMetaForm,
setGeometryMetaForm,
isEntitySubmitting,
} = useEditorStore(
useShallow((state) => ({
geometryMetaForm: state.geometryMetaForm,
setGeometryMetaForm: state.setGeometryMetaForm,
isEntitySubmitting: state.isEntitySubmitting,
}))
);
const [collapsed, setCollapsed] = useState(false);
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
| {
kind: "ok" | "error";
text: string;
signature: string;
}
| null
>(null);
const geoMetaSignature = useMemo(() => {
return [
geometryMetaForm.type_key,
geometryMetaForm.time_start,
geometryMetaForm.time_end,
].join("|");
}, [
geometryMetaForm.time_end,
geometryMetaForm.time_start,
geometryMetaForm.type_key,
]);
const handleApplyGeoMeta = async () => {
setGeoApplyFeedback(null);
const result = await onApplyGeometryMetadata();
if (result.ok) {
setGeoApplyFeedback({ kind: "ok", text: "đã apply thành công", signature: geoMetaSignature });
} else if (result.error) {
setGeoApplyFeedback({ kind: "error", text: result.error, signature: geoMetaSignature });
}
};
const visibleGeoApplyFeedback =
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
const isBulkMode = selectedFeatures.length >= 2;
const isMultiEditValid = useMemo(() => {
if (selectedFeatures.length <= 1) return true;
const firstShape = selectedFeatures[0].geometry.type;
return selectedFeatures.every((f) => f.geometry.type === firstShape);
}, [selectedFeatures]);
if (!selectedFeatures || selectedFeatures.length === 0) return null;
const representativeFeature = selectedFeatures[0];
const canRerollGeometryId =
!isBulkMode &&
representativeFeature.properties.source !== "ref" &&
Boolean(onRerollGeometryId);
const groupedGeometryTypeOptions = groupGeometryTypeOptions(GEOMETRY_TYPE_OPTIONS);
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id)
);
const selectedTypeOption = findGeometryTypeOption(geometryMetaForm.type_key);
const hasCurrentVisibleTypeOption = groupedGeoTypeOptions.some((group) =>
group.options.some((option) => option.value === geometryMetaForm.type_key)
);
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>
{isBulkMode ? `Đang chọn ${selectedFeatures.length} Geometries` : "Geometry property"}
</div>
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? "Mo panel" : "Thu gon panel"}
aria-label={collapsed ? "Mo panel Selected Geometry" : "Thu gon panel Selected Geometry"}
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "#e2e8f0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "0 0 auto",
}}
>
{collapsed ? <PlusIcon /> : <MinusIcon />}
</button>
</div>
{collapsed ? null : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
{isBulkMode && (
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #334155",
borderRadius: "8px",
padding: "8px",
background: "#1e293b",
}}
>
<div style={{ color: "#93c5fd", fontWeight: 700, fontSize: "12px" }}>
HÀNH ĐNG NHANH
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px" }}>
<button
type="button"
onClick={() => onReplayEdit?.(representativeFeature.properties.id)}
style={{
border: "none",
borderRadius: "6px",
padding: "8px 10px",
cursor: "pointer",
background: "#2563eb",
color: "#ffffff",
fontWeight: 700,
fontSize: "13px",
textAlign: "center",
gridColumn: "span 2",
}}
>
Vào Replay ({selectedFeatures.length} geo)
</button>
<button
type="button"
onClick={() => onDeleteFeatures?.(selectedFeatures.map(f => f.properties.id))}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 10px",
cursor: "pointer",
background: "#dc2626",
color: "#ffffff",
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
}}
>
Xóa ({selectedFeatures.length} geo)
</button>
<button
type="button"
onClick={() => onDeselectAll?.()}
style={{
border: "1px solid #475569",
borderRadius: "6px",
padding: "7px 10px",
cursor: "pointer",
background: "transparent",
color: "#cbd5e1",
fontWeight: 600,
fontSize: "12px",
textAlign: "center",
}}
>
Bỏ chọn tất cả
</button>
</div>
</div>
)}
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #243244",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
Thuộc tính GEO
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ color: "#94a3b8", fontSize: "11px", overflowWrap: "anywhere", minWidth: 0, flex: 1 }}>
{isBulkMode ? `Đang chọn ${selectedFeatures.length} geometries` : `ID: ${representativeFeature.properties.id}`}
</div>
{canRerollGeometryId && onRerollGeometryId && (
<button
type="button"
onClick={() => onRerollGeometryId(representativeFeature.properties.id)}
title="Đổi mã ID để sinh ngẫu nhiên màu sắc mới cho hình học này"
style={{
border: "1px solid #0f766e",
borderRadius: "4px",
padding: "2px 6px",
background: "transparent",
color: "#14b8a6",
fontSize: "11px",
cursor: "pointer",
whiteSpace: "nowrap",
flex: "0 0 auto",
}}
>
Đi màu (Reroll ID)
</button>
)}
</div>
{!isMultiEditValid ? (
<div style={{ color: "#fca5a5", fontSize: "12px", padding: "8px", border: "1px solid #7f1d1d", borderRadius: "6px", background: "#450a0a", marginTop: "4px" }}>
Không thể chỉnh sửa thuộc tính cho các geometry không cùng loại hình dạng (Point, Line, Polygon).
</div>
) : (
<>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Loại GEO
</div>
<select
value={geometryMetaForm.type_key}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
type_key: event.target.value,
}))
}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!hasCurrentVisibleTypeOption && geometryMetaForm.type_key ? (
<option value={geometryMetaForm.type_key}>
Custom Type ({geometryMetaForm.type_key})
</option>
) : null}
{groupedGeoTypeOptions.map((group) => (
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
{selectedTypeOption ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : geometryMetaForm.type_key ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Đang chọn: <b>{geometryMetaForm.type_key}</b>
</div>
) : null}
<input
value={geometryMetaForm.time_start}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
time_start: event.target.value,
}))
}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) =>
setGeometryMetaForm((prev) => ({
...prev,
time_end: event.target.value,
}))
}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
type="button"
onClick={handleApplyGeoMeta}
disabled={isEntitySubmitting}
style={primaryGeometryButtonStyle}
>
{isBulkMode ? `Apply cho ${selectedFeatures.length} geo` : "Apply"}
</button>
{onReplayEdit && !isBulkMode && selectedFeatures.length > 0 && (
<button
type="button"
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
style={{
...primaryGeometryButtonStyle,
background: "#1e293b",
border: "1px solid #334155",
color: "#38bdf8",
}}
>
Replay Edit
</button>
)}
{visibleGeoApplyFeedback ? (
<div
style={{
fontSize: "12px",
color:
visibleGeoApplyFeedback.kind === "ok" ? "#22c55e" : "#fca5a5",
}}
>
{visibleGeoApplyFeedback.text}
</div>
) : null}
</>
)}
</div>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Thay đi sẽ vào lịch sử khi Commit.
</div>
) : null}
</div>
)}
</div>
);
}
const entityInputStyle: CSSProperties = {
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
};
const primaryGeometryButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: "pointer",
background: "#0f766e",
color: "#ffffff",
fontWeight: 600,
};
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function resolveFeatureGeometryPreset(feature: Feature): GeometryPreset {
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
if (explicitPreset) return explicitPreset;
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
if (semanticType) {
const option = findGeometryTypeOption(semanticType);
if (option) return option.geometryPreset;
}
return mapGeometryTypeToPreset(feature.geometry.type);
}
function normalizeGeometryPreset(value: unknown): GeometryPreset | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (
normalized === "point" ||
normalized === "line" ||
normalized === "polygon" ||
normalized === "circle-area"
) {
return normalized;
}
return null;
}
function normalizeTypeId(value: unknown): string | null {
return normalizeGeoTypeKey(value);
}
function mapGeometryTypeToPreset(
geometryType: Feature["geometry"]["type"]
): GeometryPreset {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "point";
}
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return "line";
}
return "polygon";
}
function getAllowedGroupIdsForPreset(
geometryPreset: GeometryPreset
): GeometryTypeGroupId[] {
if (geometryPreset === "point") {
return ["point"];
}
if (geometryPreset === "line") {
return ["line"];
}
if (geometryPreset === "circle-area") {
return ["circle"];
}
return ["polygon"];
}
export default memo(SelectedGeometryPanel);
+66
View File
@@ -0,0 +1,66 @@
type SubmitModalProps = {
isSubmitModalOpen: boolean;
submitContent: string;
setSubmitContent: (content: string) => void;
handleCancelSubmit: () => void;
handleConfirmSubmit: () => void;
};
export function SubmitModal({
isSubmitModalOpen,
submitContent,
setSubmitContent,
handleCancelSubmit,
handleConfirmSubmit,
}: SubmitModalProps) {
if (!isSubmitModalOpen) return null;
const textAreaStyle = {
width: "100%",
marginTop: 8,
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: "#0b1220",
color: "white",
boxSizing: "border-box",
fontSize: 13,
outline: "none",
resize: "vertical",
fontFamily: "inherit",
height: 100,
} as const;
return (
<div style={{
position: "fixed",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000
}}>
<div style={{
background: "#0b1220",
padding: 20,
borderRadius: 8,
border: "1px solid #334155",
width: 400,
color: "white"
}}>
<h3 style={{ marginTop: 0 }}>Nội dung Submit</h3>
<textarea
value={submitContent}
onChange={(e) => setSubmitContent(e.target.value)}
placeholder="Nhập nội dung submit..."
style={textAreaStyle}
/>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10, marginTop: 15 }}>
<button onClick={handleCancelSubmit} style={{ padding: "8px 16px", borderRadius: 6, cursor: "pointer", border: "1px solid #334155", background: "transparent", color: "white" }}>Hủy</button>
<button onClick={handleConfirmSubmit} style={{ padding: "8px 16px", borderRadius: 6, cursor: "pointer", border: "none", background: "#16a34a", color: "white", fontWeight: "bold" }}>Gửi Submit</button>
</div>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { Panel } from "./Panel";
import { ModeHint } from "./ModeHint";
type ToolsPanelProps = {
mode: EditorMode;
setMode: (mode: EditorMode) => void;
onUndo: () => void;
};
export function ToolsPanel({ mode, setMode, onUndo }: ToolsPanelProps) {
const toggleMode = (newMode: EditorMode) => {
if (mode === newMode) {
setMode("idle");
} else {
setMode(newMode);
}
};
const modeButtonStyle = (btnMode: EditorMode) =>
({
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #334155",
background: mode === btnMode ? "#16a34a" : "#111827",
color: "white",
cursor: "pointer",
fontWeight: 800,
fontSize: 12,
minHeight: 34,
boxSizing: "border-box",
}) as const;
return (
<Panel title="Tools" defaultOpen>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button style={modeButtonStyle("select")} onClick={() => toggleMode("select")} title="Select">
Select
</button>
<button style={modeButtonStyle("draw")} onClick={() => toggleMode("draw")} title="Draw polygon">
Draw
</button>
<button style={modeButtonStyle("add-point")} onClick={() => setMode("add-point")} title="Add point">
Point
</button>
<button style={modeButtonStyle("add-line")} onClick={() => setMode("add-line")} title="Add line">
Line
</button>
<button style={modeButtonStyle("add-path")} onClick={() => setMode("add-path")} title="Add path">
Path
</button>
<button style={modeButtonStyle("add-circle")} onClick={() => setMode("add-circle")} title="Add circle">
Circle
</button>
</div>
<div style={{ marginTop: 10, fontSize: 12, color: "#94a3b8" }}>
Mode: <span style={{ color: "white", fontWeight: 850 }}>{mode}</span>
</div>
<ModeHint mode={mode} />
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button
style={{
...modeButtonStyle("idle"),
background: "#111827",
}}
onClick={() => setMode("idle")}
title="Tắt tool hiện tại"
>
Idle
</button>
<button
style={{
...modeButtonStyle("idle"),
background: "#334155",
}}
onClick={onUndo}
title="Undo thao tác gần nhất"
>
Undo
</button>
</div>
</Panel>
);
}
@@ -0,0 +1,59 @@
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
import { Panel } from "./Panel";
type UndoListPanelProps = {
undoStack: UndoAction[];
};
export function UndoListPanel({ undoStack }: UndoListPanelProps) {
const recentUndoLabels = (() => {
const seen = new Set<string>();
const labels: string[] = [];
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
const label = formatUndoLabel(undoStack[i]);
if (seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels.reverse();
})();
return (
<Panel title="Undo List" badge={String(recentUndoLabels.length)} defaultOpen={false}>
{recentUndoLabels.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa thao tác</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 13, color: "#e2e8f0" }}>
{recentUndoLabels.map((label, idx) => (
<li key={`${label}-${idx}`} style={{ padding: "6px 0", borderBottom: "1px solid #1f2937" }}>
{label}
</li>
))}
</ul>
)}
</Panel>
);
}
export function formatUndoLabel(action: UndoAction) {
switch (action.type) {
case "create":
return `Thêm mới #${action.id}`;
case "delete":
return `Xóa #${action.feature.properties.id}`;
case "update":
return `Chỉnh sửa #${action.id}`;
case "properties":
return `Cập nhật thuộc tính #${action.id}`;
case "snapshot_entities":
case "snapshot_wikis":
case "snapshot_entity_wiki":
case "replay":
case "replays":
case "replay_session":
case "group":
return action.label;
default:
return "Tác vụ";
}
}
+465
View File
@@ -0,0 +1,465 @@
import maplibregl from "maplibre-gl";
export type MapImageOverlay = {
url: string;
name: string;
opacity: number;
aspectRatio: number;
coordinates: maplibregl.Coordinates;
};
const IMAGE_OVERLAY_SOURCE_ID = "uhm-image-overlay-source";
const IMAGE_OVERLAY_LAYER_ID = "uhm-image-overlay-layer";
const IMAGE_OVERLAY_CONTROL_SOURCE_ID = "uhm-image-overlay-control-source";
const IMAGE_OVERLAY_HANDLE_LAYER_ID = "uhm-image-overlay-handles";
const IMAGE_OVERLAY_CENTER_LAYER_ID = "uhm-image-overlay-center";
type OverlayControlAction = "move" | "resize";
type OverlayResizeEdge = "top" | "right" | "bottom" | "left";
type OverlayControlFeature = GeoJSON.Feature<GeoJSON.Point, {
action: OverlayControlAction;
edge?: OverlayResizeEdge;
}>;
export function applyImageOverlay(
map: maplibregl.Map,
overlay: MapImageOverlay | null | undefined
) {
if (!overlay) {
removeImageOverlay(map);
return;
}
const existingSource = map.getSource(IMAGE_OVERLAY_SOURCE_ID) as maplibregl.ImageSource | undefined;
if (existingSource) {
if (existingSource.url === overlay.url) {
existingSource.setCoordinates(overlay.coordinates);
} else {
existingSource.updateImage({
url: overlay.url,
coordinates: overlay.coordinates,
});
}
} else {
map.addSource(IMAGE_OVERLAY_SOURCE_ID, {
type: "image",
url: overlay.url,
coordinates: overlay.coordinates,
});
}
if (!map.getLayer(IMAGE_OVERLAY_LAYER_ID)) {
map.addLayer({
id: IMAGE_OVERLAY_LAYER_ID,
type: "raster",
source: IMAGE_OVERLAY_SOURCE_ID,
paint: {
"raster-opacity": clampOpacity(overlay.opacity),
"raster-fade-duration": 0,
"raster-resampling": "linear",
},
});
} else {
map.setPaintProperty(IMAGE_OVERLAY_LAYER_ID, "raster-opacity", clampOpacity(overlay.opacity));
map.setPaintProperty(IMAGE_OVERLAY_LAYER_ID, "raster-fade-duration", 0);
}
// Không truyền beforeId để layer được đưa lên trên cùng, phục vụ trace khi vẽ.
map.moveLayer(IMAGE_OVERLAY_LAYER_ID);
applyImageOverlayControls(map, overlay);
}
export function removeImageOverlay(map: maplibregl.Map) {
removeImageOverlayControls(map);
if (map.getLayer(IMAGE_OVERLAY_LAYER_ID)) {
map.removeLayer(IMAGE_OVERLAY_LAYER_ID);
}
if (map.getSource(IMAGE_OVERLAY_SOURCE_ID)) {
map.removeSource(IMAGE_OVERLAY_SOURCE_ID);
}
}
export function getViewportImageCoordinates(
map: maplibregl.Map,
aspectRatio: number
): maplibregl.Coordinates {
const canvas = map.getCanvas();
const canvasWidth = Math.max(canvas.clientWidth || canvas.width || 800, 1);
const canvasHeight = Math.max(canvas.clientHeight || canvas.height || 600, 1);
const safeAspectRatio = normalizeAspectRatio(aspectRatio);
let width = canvasWidth * 0.72;
let height = width / safeAspectRatio;
const maxHeight = canvasHeight * 0.72;
if (height > maxHeight) {
height = maxHeight;
width = height * safeAspectRatio;
}
return buildCoordinatesFromScreenBox(
map,
{ x: canvasWidth / 2, y: canvasHeight / 2 },
width,
height
);
}
export function moveImageOverlayCoordinatesByPixels(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates,
deltaX: number,
deltaY: number
): maplibregl.Coordinates {
return moveCoordinates(map, coordinates, new maplibregl.Point(deltaX, deltaY));
}
export function scaleImageOverlayCoordinatesByFactor(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates,
factor: number,
aspectRatio: number
): maplibregl.Coordinates {
const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 1;
const screenBox = getScreenBox(map, coordinates);
const minimumSize = 48;
const width = Math.max(screenBox.width * safeFactor, minimumSize);
const height = width / normalizeAspectRatio(aspectRatio);
return buildCoordinatesFromScreenBox(map, screenBox.center, width, height);
}
export function bindImageOverlayInteractions(
map: maplibregl.Map,
getOverlay: () => MapImageOverlay | null,
onChange: (overlay: MapImageOverlay) => void
) {
let rafId: number | null = null;
let pendingCoordinates: maplibregl.Coordinates | null = null;
let latestOverlay: MapImageOverlay | null = null;
let activeDrag: {
action: OverlayControlAction;
edge: OverlayResizeEdge | null;
startPoint: maplibregl.Point;
startCoordinates: maplibregl.Coordinates;
startBox: ScreenBox;
aspectRatio: number;
wasDragPanEnabled: boolean;
} | null = null;
const startDrag = (event: maplibregl.MapLayerMouseEvent) => {
if ((event.originalEvent as MouseEvent | undefined)?.button !== 2) return;
const overlay = getOverlay();
const feature = event.features?.[0] as OverlayControlFeature | undefined;
if (!overlay || !feature?.properties?.action) return;
event.preventDefault();
event.originalEvent.preventDefault();
event.originalEvent.stopPropagation();
activeDrag = {
action: feature.properties.action,
edge: feature.properties.edge || null,
startPoint: event.point,
startCoordinates: overlay.coordinates,
startBox: getScreenBox(map, overlay.coordinates),
aspectRatio: normalizeAspectRatio(overlay.aspectRatio),
wasDragPanEnabled: map.dragPan.isEnabled(),
};
latestOverlay = overlay;
map.dragPan.disable();
map.getCanvas().style.cursor = activeDrag.action === "move" ? "grabbing" : "nwse-resize";
};
const moveDrag = (event: maplibregl.MapMouseEvent) => {
if (!activeDrag) return;
const overlay = getOverlay();
if (!overlay) return;
event.preventDefault();
const nextCoordinates = activeDrag.action === "move"
? moveCoordinates(map, activeDrag.startCoordinates, event.point.sub(activeDrag.startPoint))
: resizeCoordinates(map, activeDrag.startBox, event.point, activeDrag.edge, activeDrag.aspectRatio);
latestOverlay = {
...overlay,
coordinates: nextCoordinates,
};
scheduleImageOverlayCoordinateUpdate(map, nextCoordinates);
};
const endDrag = () => {
if (!activeDrag) return;
const finishedDrag = activeDrag;
activeDrag = null;
flushImageOverlayCoordinateUpdate(map);
if (latestOverlay) {
onChange(latestOverlay);
latestOverlay = null;
}
if (finishedDrag.wasDragPanEnabled && !map.dragPan.isEnabled()) {
map.dragPan.enable();
}
map.getCanvas().style.cursor = "";
};
const preventContextMenu = (event: maplibregl.MapLayerMouseEvent) => {
event.preventDefault();
event.originalEvent.preventDefault();
event.originalEvent.stopPropagation();
};
map.on("mousedown", IMAGE_OVERLAY_HANDLE_LAYER_ID, startDrag);
map.on("mousedown", IMAGE_OVERLAY_CENTER_LAYER_ID, startDrag);
map.on("mousemove", moveDrag);
map.on("mouseup", endDrag);
map.on("contextmenu", IMAGE_OVERLAY_HANDLE_LAYER_ID, preventContextMenu);
map.on("contextmenu", IMAGE_OVERLAY_CENTER_LAYER_ID, preventContextMenu);
return () => {
endDrag();
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
pendingCoordinates = null;
map.off("mousedown", IMAGE_OVERLAY_HANDLE_LAYER_ID, startDrag);
map.off("mousedown", IMAGE_OVERLAY_CENTER_LAYER_ID, startDrag);
map.off("mousemove", moveDrag);
map.off("mouseup", endDrag);
map.off("contextmenu", IMAGE_OVERLAY_HANDLE_LAYER_ID, preventContextMenu);
map.off("contextmenu", IMAGE_OVERLAY_CENTER_LAYER_ID, preventContextMenu);
};
function scheduleImageOverlayCoordinateUpdate(
targetMap: maplibregl.Map,
coordinates: maplibregl.Coordinates
) {
pendingCoordinates = coordinates;
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
flushImageOverlayCoordinateUpdate(targetMap);
});
}
function flushImageOverlayCoordinateUpdate(targetMap: maplibregl.Map) {
if (!pendingCoordinates) return;
updateImageOverlayCoordinates(targetMap, pendingCoordinates);
pendingCoordinates = null;
}
}
export function getImageOverlayInteractiveLayerIds() {
return [IMAGE_OVERLAY_HANDLE_LAYER_ID, IMAGE_OVERLAY_CENTER_LAYER_ID];
}
function applyImageOverlayControls(map: maplibregl.Map, overlay: MapImageOverlay) {
const data = buildControlFeatureCollection(overlay.coordinates);
const existingSource = map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (existingSource) {
existingSource.setData(data);
} else {
map.addSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID, {
type: "geojson",
data,
});
}
if (!map.getLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID)) {
map.addLayer({
id: IMAGE_OVERLAY_HANDLE_LAYER_ID,
type: "circle",
source: IMAGE_OVERLAY_CONTROL_SOURCE_ID,
filter: ["==", ["get", "action"], "resize"],
paint: {
"circle-color": "#38bdf8",
"circle-radius": 7,
"circle-stroke-color": "#0f172a",
"circle-stroke-width": 2,
},
});
}
if (!map.getLayer(IMAGE_OVERLAY_CENTER_LAYER_ID)) {
map.addLayer({
id: IMAGE_OVERLAY_CENTER_LAYER_ID,
type: "circle",
source: IMAGE_OVERLAY_CONTROL_SOURCE_ID,
filter: ["==", ["get", "action"], "move"],
paint: {
"circle-color": "#fbbf24",
"circle-radius": 8,
"circle-stroke-color": "#0f172a",
"circle-stroke-width": 2,
},
});
}
map.moveLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID);
map.moveLayer(IMAGE_OVERLAY_CENTER_LAYER_ID);
}
function updateImageOverlayCoordinates(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates
) {
const imageSource = map.getSource(IMAGE_OVERLAY_SOURCE_ID) as maplibregl.ImageSource | undefined;
imageSource?.setCoordinates(coordinates);
const controlSource = map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
controlSource?.setData(buildControlFeatureCollection(coordinates));
}
function removeImageOverlayControls(map: maplibregl.Map) {
if (map.getLayer(IMAGE_OVERLAY_CENTER_LAYER_ID)) {
map.removeLayer(IMAGE_OVERLAY_CENTER_LAYER_ID);
}
if (map.getLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID)) {
map.removeLayer(IMAGE_OVERLAY_HANDLE_LAYER_ID);
}
if (map.getSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID)) {
map.removeSource(IMAGE_OVERLAY_CONTROL_SOURCE_ID);
}
}
function buildControlFeatureCollection(
coordinates: maplibregl.Coordinates
): GeoJSON.FeatureCollection<GeoJSON.Point, OverlayControlFeature["properties"]> {
const [topLeft, topRight, bottomRight, bottomLeft] = coordinates;
const center = averageCoordinates(coordinates);
return [
createControlFeature(center, { action: "move" }),
createControlFeature(midpoint(topLeft, topRight), { action: "resize", edge: "top" }),
createControlFeature(midpoint(topRight, bottomRight), { action: "resize", edge: "right" }),
createControlFeature(midpoint(bottomRight, bottomLeft), { action: "resize", edge: "bottom" }),
createControlFeature(midpoint(bottomLeft, topLeft), { action: "resize", edge: "left" }),
].reduce<GeoJSON.FeatureCollection<GeoJSON.Point, OverlayControlFeature["properties"]>>(
(collection, feature) => {
collection.features.push(feature);
return collection;
},
{ type: "FeatureCollection", features: [] }
);
}
function createControlFeature(
coordinates: [number, number],
properties: OverlayControlFeature["properties"]
): OverlayControlFeature {
return {
type: "Feature",
properties,
geometry: {
type: "Point",
coordinates,
},
};
}
type ScreenPoint = { x: number; y: number };
type ScreenBox = {
center: ScreenPoint;
width: number;
height: number;
};
function getScreenBox(map: maplibregl.Map, coordinates: maplibregl.Coordinates): ScreenBox {
const points = coordinates.map((coordinate) => map.project(coordinate));
const minX = Math.min(...points.map((point) => point.x));
const maxX = Math.max(...points.map((point) => point.x));
const minY = Math.min(...points.map((point) => point.y));
const maxY = Math.max(...points.map((point) => point.y));
return {
center: {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
},
width: Math.max(maxX - minX, 40),
height: Math.max(maxY - minY, 40),
};
}
function moveCoordinates(
map: maplibregl.Map,
coordinates: maplibregl.Coordinates,
delta: maplibregl.Point
): maplibregl.Coordinates {
return coordinates.map((coordinate) => {
const point = map.project(coordinate);
return lngLatToCoordinate(map.unproject([point.x + delta.x, point.y + delta.y]));
}) as maplibregl.Coordinates;
}
function resizeCoordinates(
map: maplibregl.Map,
startBox: ScreenBox,
currentPoint: maplibregl.Point,
edge: OverlayResizeEdge | null,
aspectRatio: number
): maplibregl.Coordinates {
const minimumSize = 48;
let width = startBox.width;
let height = startBox.height;
if (edge === "left" || edge === "right") {
width = Math.max(Math.abs(currentPoint.x - startBox.center.x) * 2, minimumSize);
height = width / aspectRatio;
} else {
height = Math.max(Math.abs(currentPoint.y - startBox.center.y) * 2, minimumSize);
width = height * aspectRatio;
}
return buildCoordinatesFromScreenBox(map, startBox.center, width, height);
}
function buildCoordinatesFromScreenBox(
map: maplibregl.Map,
center: ScreenPoint,
width: number,
height: number
): maplibregl.Coordinates {
const halfWidth = width / 2;
const halfHeight = height / 2;
return [
lngLatToCoordinate(map.unproject([center.x - halfWidth, center.y - halfHeight])),
lngLatToCoordinate(map.unproject([center.x + halfWidth, center.y - halfHeight])),
lngLatToCoordinate(map.unproject([center.x + halfWidth, center.y + halfHeight])),
lngLatToCoordinate(map.unproject([center.x - halfWidth, center.y + halfHeight])),
];
}
function averageCoordinates(coordinates: maplibregl.Coordinates): [number, number] {
const total = coordinates.reduce(
(sum, coordinate) => ({
lng: sum.lng + coordinate[0],
lat: sum.lat + coordinate[1],
}),
{ lng: 0, lat: 0 }
);
return [total.lng / coordinates.length, total.lat / coordinates.length];
}
function midpoint(a: [number, number], b: [number, number]): [number, number] {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
}
function lngLatToCoordinate(lngLat: maplibregl.LngLat): [number, number] {
return [lngLat.lng, lngLat.lat];
}
function normalizeAspectRatio(value: number) {
if (!Number.isFinite(value) || value <= 0) return 1;
return value;
}
function clampOpacity(value: number) {
if (!Number.isFinite(value)) return 0.55;
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
File diff suppressed because it is too large Load Diff
+700
View File
@@ -0,0 +1,700 @@
import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import type { Feature, FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
import { FEATURE_STATE_SOURCE_IDS } from "@/uhm/lib/map/constants";
export type MapHoverPopupContent = {
key?: string;
rows: Array<{
title: string;
titleTone?: "danger" | "default";
isGroupHeader?: boolean;
description?: string | null;
titleTimeRange?: string | null;
titleTimeRangeTone?: "success" | "muted";
separatorBefore?: boolean;
quote?: string | null;
quoteTone?: "danger" | "default";
onClick?: () => void;
}>;
};
type UseMapHoverPopupProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
enabled: boolean;
renderDraftRef: React.MutableRefObject<FeatureCollection>;
getContentRef: React.MutableRefObject<((feature: Feature) => MapHoverPopupContent | null) | undefined>;
onHoverFeatureChangeRef?: React.MutableRefObject<((feature: Feature | null) => void) | undefined>;
};
export function useMapHoverPopup({
mapRef,
enabled,
renderDraftRef,
getContentRef,
onHoverFeatureChangeRef,
}: UseMapHoverPopupProps) {
const enabledRef = useRef(enabled);
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
useEffect(() => {
const map = mapRef.current;
if (!map || !enabled) return;
// Disable hover popup if the device has no hover capability (mobile/tablet)
const hasHoverSupport = window.matchMedia("(hover: hover)").matches;
if (!hasHoverSupport) return;
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
offset: 12,
className: "uhm-map-hover-popup",
});
let hoveredKey: string | null = null;
let frameId: number | null = null;
let pendingEvent: maplibregl.MapMouseEvent | null = null;
let lastMapMouseEvent: maplibregl.MapMouseEvent | null = null;
let currentContent: MapHoverPopupContent | null = null;
let selectedRowIndex = 0;
let selectionVisible = false;
let selectionDirection: "next" | "prev" | null = null;
let lastSelectedRowClick: (() => void) | null = null;
let lastSelectedRowAt = 0;
let hoverLayerIds = getHoverLayerIds(map);
let activeFeatureId: string | null = null;
let featureLookupDraft: FeatureCollection | null = null;
let featureById = new Map<string, Feature>();
const refreshHoverLayerIds = () => {
hoverLayerIds = getHoverLayerIds(map);
};
const getSourceFeatureById = (id: string) => {
const draft = renderDraftRef.current;
if (draft !== featureLookupDraft) {
featureLookupDraft = draft;
featureById = new Map(draft.features.map((item) => [String(item.properties.id), item]));
}
return featureById.get(id) || null;
};
const removePopup = () => {
hoveredKey = null;
currentContent = null;
selectedRowIndex = 0;
selectionVisible = false;
if (activeFeatureId !== null) {
activeFeatureId = null;
onHoverFeatureChangeRef?.current?.(null);
}
popup.remove();
};
const getCurrentRows = () => getSelectableRows(currentContent);
const syncSelectedRow = () => {
const rows = getCurrentRows();
if (!rows.length) return;
selectedRowIndex = wrapIndex(selectedRowIndex, rows.length);
lastSelectedRowClick = rows[selectedRowIndex]?.onClick || null;
lastSelectedRowAt = Date.now();
updatePopupRowSelection(popup, selectedRowIndex, selectionVisible, selectionDirection);
};
const cyclePopupRow = (direction: "next" | "prev") => {
const rows = getCurrentRows();
if (!rows.length) return;
const currentIndex = wrapIndex(selectedRowIndex, rows.length);
const nextIndex = direction === "prev"
? Math.max(0, currentIndex - 1)
: Math.min(rows.length - 1, currentIndex + 1);
selectionDirection = direction;
if (nextIndex === currentIndex) {
selectedRowIndex = currentIndex;
syncSelectedRow();
return;
}
selectedRowIndex = nextIndex;
syncSelectedRow();
};
const onWheel = (event: WheelEvent) => {
if (!event.shiftKey) return;
if (isEditableEventTarget(event.target)) return;
const rows = getCurrentRows();
if (!rows.length) return;
const target = event.target instanceof HTMLElement ? event.target : null;
const insidePopup = Boolean(target?.closest(".uhm-map-hover-popup"));
const insideMap = target ? map.getContainer().contains(target) : false;
if (!insidePopup && !insideMap) return;
const direction = event.deltaY > 0 ? "next" : event.deltaY < 0 ? "prev" : null;
if (!direction) return;
event.preventDefault();
event.stopPropagation();
selectionVisible = true;
cyclePopupRow(direction);
};
const onCommitPopupRow = () => {
const rows = getCurrentRows();
const selectedRowClick = rows.length
? rows[wrapIndex(selectedRowIndex, rows.length)]?.onClick
: (Date.now() - lastSelectedRowAt < 1200 ? lastSelectedRowClick : null);
selectedRowClick?.();
if (rows.length || selectedRowClick) {
removePopup();
}
};
const requestPopupUpdateFromLastMouseEvent = () => {
if (!lastMapMouseEvent || !enabledRef.current) return;
pendingEvent = lastMapMouseEvent;
if (frameId !== null) return;
frameId = window.requestAnimationFrame(updatePopup);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Shift") {
if (event.repeat) return;
if (isEditableEventTarget(event.target)) return;
selectionVisible = true;
updatePopupRowSelection(popup, selectedRowIndex, selectionVisible, selectionDirection);
requestPopupUpdateFromLastMouseEvent();
return;
}
if (event.key !== "Enter") return;
if (isEditableEventTarget(event.target)) return;
if (!getCurrentRows().length) return;
event.preventDefault();
event.stopPropagation();
onCommitPopupRow();
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key !== "Shift") return;
if (isEditableEventTarget(event.target)) return;
if (!getCurrentRows().length && lastMapMouseEvent) {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
frameId = null;
}
pendingEvent = pendingEvent || lastMapMouseEvent;
updatePopup();
}
event.preventDefault();
event.stopPropagation();
onCommitPopupRow();
};
const updatePopup = () => {
frameId = null;
const event = pendingEvent;
pendingEvent = null;
if (!event || !enabledRef.current) {
removePopup();
return;
}
if (!hoverLayerIds.length) {
removePopup();
return;
}
let features: maplibregl.MapGeoJSONFeature[];
try {
features = map.queryRenderedFeatures(event.point, { layers: hoverLayerIds }) as maplibregl.MapGeoJSONFeature[];
} catch {
refreshHoverLayerIds();
removePopup();
return;
}
if (!features.length) {
removePopup();
return;
}
const renderedFeature = pickPreferredFeature(features);
const rawId = renderedFeature.id ?? renderedFeature.properties?.id;
if (rawId === undefined || rawId === null) {
removePopup();
return;
}
const id = String(rawId);
const sourceFeature = getSourceFeatureById(id);
if (!sourceFeature) {
removePopup();
return;
}
if (id !== activeFeatureId) {
activeFeatureId = id;
onHoverFeatureChangeRef?.current?.(sourceFeature);
}
const content = getContentRef.current?.(sourceFeature) || null;
if (!content?.rows?.some((row) => row.title.trim())) {
removePopup();
return;
}
const contentKey = buildContentKey(id, content);
const contentChanged = contentKey !== hoveredKey;
const shouldStylePopup = contentChanged || !popup.isOpen();
if (contentKey !== hoveredKey) {
hoveredKey = contentKey;
currentContent = content;
selectedRowIndex = 0;
if (!selectionVisible) {
selectionVisible = Boolean(event.originalEvent?.shiftKey);
}
popup.setDOMContent(buildPopupNode(content, selectedRowIndex, selectionVisible));
}
popup.setLngLat(event.lngLat);
if (!popup.isOpen()) {
popup.addTo(map);
}
if (shouldStylePopup) {
stylePopupChrome(popup);
}
if (contentChanged) {
syncSelectedRow();
}
};
const onMouseMove = (event: maplibregl.MapMouseEvent) => {
lastMapMouseEvent = event;
pendingEvent = event;
if (frameId !== null) return;
frameId = window.requestAnimationFrame(updatePopup);
};
const onMouseOut = () => {
lastMapMouseEvent = null;
pendingEvent = null;
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
frameId = null;
}
removePopup();
};
map.on("mousemove", onMouseMove);
map.on("mouseout", onMouseOut);
map.on("dragstart", removePopup);
map.on("zoomstart", removePopup);
map.on("styledata", refreshHoverLayerIds);
window.addEventListener("wheel", onWheel, { passive: false, capture: true });
window.addEventListener("keydown", onKeyDown, { capture: true });
window.addEventListener("keyup", onKeyUp, { capture: true });
return () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
map.off("mousemove", onMouseMove);
map.off("mouseout", onMouseOut);
map.off("dragstart", removePopup);
map.off("zoomstart", removePopup);
map.off("styledata", refreshHoverLayerIds);
window.removeEventListener("wheel", onWheel, { capture: true });
window.removeEventListener("keydown", onKeyDown, { capture: true });
window.removeEventListener("keyup", onKeyUp, { capture: true });
popup.remove();
};
}, [enabled, getContentRef, mapRef, onHoverFeatureChangeRef, renderDraftRef]);
}
function getHoverLayerIds(map: maplibregl.Map): string[] {
const style = map.getStyle();
if (!style?.layers) return [];
return style.layers
.filter((layer) =>
"source" in layer &&
typeof layer.source === "string" &&
FEATURE_STATE_SOURCE_IDS.includes(layer.source as (typeof FEATURE_STATE_SOURCE_IDS)[number])
)
.map((layer) => layer.id);
}
function pickPreferredFeature(features: maplibregl.MapGeoJSONFeature[]) {
let best = features[0];
let bestPriority = featureSelectPriority(best);
for (let index = 1; index < features.length; index += 1) {
const candidate = features[index];
const priority = featureSelectPriority(candidate);
if (priority > bestPriority) {
best = candidate;
bestPriority = priority;
}
}
return best;
}
function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) {
const layerId = typeof feature.layer?.id === "string" ? feature.layer.id : "";
const geometryType = feature.geometry?.type;
const source = typeof feature.source === "string" ? feature.source : "";
if (layerId.endsWith("-hit")) return 400;
if (source === "path-arrow-shapes") return 300;
if (geometryType === "LineString" || geometryType === "MultiLineString") return 200;
if (geometryType === "Point" || geometryType === "MultiPoint") return 100;
return 0;
}
function buildPopupNode(content: MapHoverPopupContent, selectedRowIndex: number, selectionVisible: boolean): HTMLElement {
ensureHoverPopupScrollbarStyle();
const root = document.createElement("div");
root.className = "uhm-map-hover-popup-body";
root.dataset.hoverPopupScrollRoot = "true";
root.style.width = "320px";
root.style.maxWidth = "calc(100vw - 2rem)";
root.style.maxHeight = "300px";
root.style.overflowY = "auto";
root.style.padding = "12px";
root.style.border = "1px solid rgba(255, 255, 255, 0.10)";
root.style.borderRadius = "0px";
root.style.background = "rgba(2, 6, 23, 0.95)";
root.style.boxShadow = "0 18px 36px rgba(0, 0, 0, 0.35)";
root.style.backdropFilter = "blur(8px)";
root.style.color = "#e2e8f0";
const grid = document.createElement("div");
grid.style.display = "grid";
grid.style.gap = "5px";
root.appendChild(grid);
const rows = normalizeRows(content);
let selectableRowIndex = 0;
rows.forEach((row) => {
const titleText = row.title.trim();
if (row.separatorBefore) {
const separator = document.createElement("div");
separator.style.height = "1px";
separator.style.margin = "2px 0";
separator.style.background = "rgba(255, 255, 255, 0.16)";
separator.style.pointerEvents = "none";
grid.appendChild(separator);
}
const isGroupHeader = Boolean(row.isGroupHeader);
const rowSelectionIndex = isGroupHeader ? null : selectableRowIndex++;
const card: HTMLButtonElement | HTMLDivElement = row.onClick
? document.createElement("button")
: document.createElement("div");
if (row.onClick) {
(card as HTMLButtonElement).type = "button";
card.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
row.onClick?.();
};
}
card.style.width = isGroupHeader ? "100%" : "calc(100% - 18px)";
card.style.marginLeft = isGroupHeader ? "0" : "18px";
card.style.border = isGroupHeader ? "0" : "1px solid transparent";
card.style.borderRadius = "0px";
card.style.background = "transparent";
card.style.padding = isGroupHeader ? "8px 2px 3px" : "7px 9px";
card.style.textAlign = "left";
card.style.font = "inherit";
card.style.cursor = row.onClick ? "pointer" : "default";
card.style.display = "block";
card.style.outline = "none";
card.style.appearance = "none";
card.style.webkitAppearance = "none";
card.style.transition = "border-color 140ms ease, background 140ms ease";
if (rowSelectionIndex !== null) {
card.dataset.hoverPopupRowIndex = String(rowSelectionIndex);
}
if (isGroupHeader) {
card.dataset.hoverPopupGroupHeader = "true";
}
if (row.onClick) {
card.onmouseenter = () => {
if (card.dataset.hoverPopupSelected === "true") return;
card.style.borderColor = "transparent";
card.style.background = "rgba(14, 165, 233, 0.08)";
};
card.onmouseleave = () => {
if (card.dataset.hoverPopupSelected === "true") {
applyPopupRowStyle(card, true);
return;
}
card.style.borderColor = "transparent";
card.style.background = "transparent";
};
}
const title = document.createElement("div");
title.style.fontSize = isGroupHeader ? "14px" : "13px";
title.style.fontWeight = isGroupHeader ? "800" : "650";
title.style.lineHeight = isGroupHeader ? "20px" : "18px";
title.style.color = row.titleTone === "danger" ? "#f87171" : (isGroupHeader ? "#ffffff" : "#e5edf7");
title.style.overflow = "hidden";
title.style.textOverflow = "ellipsis";
title.style.whiteSpace = "nowrap";
const descriptionText = row.description?.trim();
if (row.titleTimeRange?.trim()) {
const nameSpan = document.createElement("span");
nameSpan.textContent = titleText;
title.appendChild(nameSpan);
const rangeSpan = document.createElement("span");
rangeSpan.textContent = ` (${row.titleTimeRange.trim()})`;
rangeSpan.style.color = row.titleTimeRangeTone === "success" ? "#34d399" : "#94a3b8";
rangeSpan.style.fontWeight = "700";
title.appendChild(rangeSpan);
} else {
title.textContent = titleText;
}
card.appendChild(title);
if (isGroupHeader && descriptionText) {
const descriptionWrap = document.createElement("div");
descriptionWrap.className = "uhm-map-hover-popup-description-marquee";
descriptionWrap.title = descriptionText;
const descriptionSpan = document.createElement("span");
descriptionSpan.textContent = descriptionText;
descriptionWrap.appendChild(descriptionSpan);
card.appendChild(descriptionWrap);
}
const quoteText = row.quote?.trim();
if (quoteText) {
const quote = document.createElement("div");
quote.textContent = quoteText;
quote.style.marginTop = "5px";
quote.style.paddingLeft = "8px";
quote.style.paddingRight = "4px";
quote.style.borderLeft = `2px solid ${row.quoteTone === "danger" ? "rgba(248, 113, 113, 0.58)" : "rgba(56, 189, 248, 0.34)"}`;
quote.style.fontSize = "13px";
quote.style.fontStyle = "italic";
quote.style.lineHeight = "18px";
quote.style.color = row.quoteTone === "danger" ? "#f87171" : "#b8c4d4";
quote.style.display = "-webkit-box";
quote.style.webkitLineClamp = "4";
quote.style.webkitBoxOrient = "vertical";
quote.style.overflow = "hidden";
quote.style.whiteSpace = "normal";
card.appendChild(quote);
}
grid.appendChild(card);
});
updatePopupNodeRowSelection(root, selectedRowIndex, selectionVisible, null);
return root;
}
function buildContentKey(featureId: string, content: MapHoverPopupContent): string {
if (content.key) return `${featureId}:${content.key}`;
return `${featureId}:${content.rows
.map((row) => [
row.separatorBefore ? "sep" : "",
row.isGroupHeader ? "group" : "",
row.title,
row.titleTone || "",
row.description || "",
row.titleTimeRange || "",
row.titleTimeRangeTone || "",
row.quote || "",
].join(":"))
.join("|")}`;
}
function ensureHoverPopupScrollbarStyle() {
const styleId = "uhm-map-hover-popup-scrollbar-style";
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
.uhm-map-hover-popup-body {
scrollbar-width: thin;
scrollbar-color: rgba(56, 189, 248, 0.58) rgba(15, 23, 42, 0.72);
}
.uhm-map-hover-popup-body::-webkit-scrollbar {
width: 10px;
}
.uhm-map-hover-popup-body::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.72);
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
.uhm-map-hover-popup-body::-webkit-scrollbar-thumb {
min-height: 36px;
border: 2px solid rgba(2, 6, 23, 0.95);
border-radius: 999px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.86), rgba(14, 165, 233, 0.58));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
}
.uhm-map-hover-popup-body::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(125, 211, 252, 0.96), rgba(56, 189, 248, 0.72));
}
.uhm-map-hover-popup-body button,
.uhm-map-hover-popup-body button:focus,
.uhm-map-hover-popup-body button:focus-visible {
outline: none !important;
box-shadow: none;
}
.uhm-map-hover-popup-description-marquee {
display: block;
width: 100%;
margin-top: 2px;
overflow: hidden;
color: #94a3b8;
font-size: 12px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
}
.uhm-map-hover-popup-description-marquee > span {
display: inline-block;
min-width: 100%;
transform: translateX(0);
}
.uhm-map-hover-popup-description-marquee:hover > span {
animation: uhm-hover-popup-marquee 8s linear infinite alternate;
}
@keyframes uhm-hover-popup-marquee {
from { transform: translateX(0); }
to { transform: translateX(calc(-100% + 100px)); }
}
`;
document.head.appendChild(style);
}
function isEditableEventTarget(target: EventTarget | null): boolean {
const element = target instanceof HTMLElement ? target : null;
if (!element) return false;
return Boolean(element.closest("input, textarea, select, [contenteditable='true']"));
}
function normalizeRows(content: MapHoverPopupContent | null): MapHoverPopupContent["rows"] {
return (content?.rows || []).filter((row) => row.title.trim());
}
function getSelectableRows(content: MapHoverPopupContent | null): MapHoverPopupContent["rows"] {
return normalizeRows(content).filter((row) => !row.isGroupHeader);
}
function wrapIndex(index: number, length: number): number {
if (length <= 0) return 0;
return ((index % length) + length) % length;
}
function updatePopupRowSelection(
popup: maplibregl.Popup,
selectedRowIndex: number,
selectionVisible: boolean,
selectionDirection: "next" | "prev" | null
) {
updatePopupNodeRowSelection(popup.getElement() || null, selectedRowIndex, selectionVisible, selectionDirection);
}
function updatePopupNodeRowSelection(
root: HTMLElement | null,
selectedRowIndex: number,
selectionVisible: boolean,
selectionDirection: "next" | "prev" | null
) {
if (!root) return;
const cards = Array.from(root.querySelectorAll<HTMLElement>("[data-hover-popup-row-index]"));
let selectedCard: HTMLElement | null = null;
for (const card of cards) {
const rowIndex = Number(card.dataset.hoverPopupRowIndex);
const selected = selectionVisible && rowIndex === selectedRowIndex;
card.dataset.hoverPopupSelected = selected ? "true" : "false";
applyPopupRowStyle(card, selected);
if (selected) {
selectedCard = card;
}
}
if (selectedCard) {
ensurePopupRowVisible(selectedCard, selectionDirection);
window.requestAnimationFrame(() => ensurePopupRowVisible(selectedCard, selectionDirection));
}
}
function applyPopupRowStyle(card: HTMLElement, selected: boolean) {
card.style.borderColor = "transparent";
card.style.background = selected ? "rgba(14, 165, 233, 0.11)" : "transparent";
card.style.boxShadow = selected ? "inset 2px 0 0 rgba(56, 189, 248, 0.95)" : "none";
}
function ensurePopupRowVisible(card: HTMLElement, direction: "next" | "prev" | null) {
const scrollRoot = card.closest<HTMLElement>("[data-hover-popup-scroll-root='true']");
if (!scrollRoot) return;
const padding = 8;
const groupHeader = direction === "prev" ? findPreviousGroupHeader(card) : null;
const cardTop = card.offsetTop;
const cardBottom = cardTop + card.offsetHeight;
const targetTop = groupHeader?.offsetTop ?? cardTop;
const visibleTop = scrollRoot.scrollTop + padding;
const visibleBottom = scrollRoot.scrollTop + scrollRoot.clientHeight - padding;
if (targetTop < visibleTop) {
scrollRoot.scrollTop = Math.max(0, targetTop - padding);
return;
}
if (cardBottom > visibleBottom) {
scrollRoot.scrollTop = cardBottom - scrollRoot.clientHeight + padding;
}
}
function findPreviousGroupHeader(card: HTMLElement): HTMLElement | null {
let current = card.previousElementSibling;
while (current) {
if (current instanceof HTMLElement && current.dataset.hoverPopupGroupHeader === "true") {
return current;
}
current = current.previousElementSibling;
}
return null;
}
function stylePopupChrome(popup: maplibregl.Popup) {
const element = popup.getElement();
const content = element.querySelector(".maplibregl-popup-content") as HTMLElement | null;
if (content) {
content.style.padding = "0";
content.style.borderRadius = "0px";
content.style.background = "transparent";
content.style.boxShadow = "none";
}
for (const tip of Array.from(element.querySelectorAll(".maplibregl-popup-tip")) as HTMLElement[]) {
tip.style.display = "none";
}
}
+230
View File
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState, useCallback } from "react";
import maplibregl from "maplibre-gl";
import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "@/uhm/lib/map/constants";
import { clampNumber, roundZoom } from "./mapUtils";
import { getBaseMapStyle } from "./useMapLayers";
import { unregisterMapFromIconUpdates } from "@/uhm/lib/map/styles/geotypeLayers";
const MAP_PROJECTION_STORAGE_KEY = "uhm:mapProjection";
const MAP_VIEWPORT_STORAGE_KEY = "uhm:mapViewport";
export function applyMapProjection(map: maplibregl.Map, isGlobe: boolean) {
map.setProjection({ type: isGlobe ? "globe" : "mercator" });
}
export function useMapInstance() {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [fatalInitError, setFatalInitError] = useState<string | null>(null);
const [zoomLevel, setZoomLevel] = useState(2);
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
const [isGlobeProjection, setIsGlobeProjection] = useState(() => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(MAP_PROJECTION_STORAGE_KEY) === "globe";
} catch {
return false;
}
});
const [isMapLoaded, setIsMapLoaded] = useState(false);
const geolocationCenteredRef = useRef(false);
// Ref khóa sync zoom từ MapLibre trong lúc user kéo slider để tránh value bị animate ghi ngược.
const isZoomSliderDraggingRef = useRef(false);
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
MAP_PROJECTION_STORAGE_KEY,
isGlobeProjection ? "globe" : "mercator"
);
} catch {
// ignore
}
}, [isGlobeProjection]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
try {
let initialCenter: [number, number] = [0, 20];
let initialZoom = 2;
try {
const saved = window.localStorage.getItem(MAP_VIEWPORT_STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (Number.isFinite(parsed.lng) && Number.isFinite(parsed.lat) && Number.isFinite(parsed.zoom)) {
initialCenter = [parsed.lng, parsed.lat];
initialZoom = parsed.zoom;
}
}
} catch {
// ignore
}
const map = new maplibregl.Map({
container,
attributionControl: false,
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
style: getBaseMapStyle(),
center: initialCenter,
zoom: initialZoom,
});
mapRef.current = map;
const saveViewport = () => {
const currentMap = mapRef.current;
if (!currentMap) return;
try {
const center = currentMap.getCenter();
const zoom = currentMap.getZoom();
window.localStorage.setItem(
MAP_VIEWPORT_STORAGE_KEY,
JSON.stringify({ lng: center.lng, lat: center.lat, zoom })
);
} catch {
// ignore
}
};
let throttleTimeout: ReturnType<typeof setTimeout> | null = null;
const syncZoomLevelImmediate = () => {
if (isZoomSliderDraggingRef.current) return;
const currentMap = mapRef.current;
if (!currentMap) return;
const next = roundZoom(currentMap.getZoom());
setZoomLevel((prev) => (prev === next ? prev : next));
};
const syncZoomLevelThrottled = () => {
if (isZoomSliderDraggingRef.current) return;
if (throttleTimeout) return;
throttleTimeout = setTimeout(() => {
throttleTimeout = null;
syncZoomLevelImmediate();
}, 150);
};
map.on("load", () => {
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
syncZoomLevelImmediate();
map.on("zoom", syncZoomLevelThrottled);
map.on("zoomend", syncZoomLevelImmediate);
map.on("moveend", saveViewport);
setIsMapLoaded(true);
});
return () => {
if (throttleTimeout) {
clearTimeout(throttleTimeout);
}
map.off("zoom", syncZoomLevelThrottled);
map.off("zoomend", syncZoomLevelImmediate);
map.off("moveend", saveViewport);
setIsMapLoaded(false);
if (mapRef.current === map) {
mapRef.current = null;
}
unregisterMapFromIconUpdates(map);
map.remove();
};
} catch (err) {
console.error("Map initialization failed", err);
const message = err instanceof Error ? err.message : "Map initialization failed.";
window.setTimeout(() => setFatalInitError(message), 0);
}
}, []);
// Sync Map Projection
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
if (mapRef.current !== map) return;
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
applyMapProjection(map, isGlobeProjection);
};
if (typeof map.isStyleLoaded === "function" && map.isStyleLoaded()) {
apply();
return;
}
map.once("load", apply);
map.once("style.load", apply);
return () => {
map.off("load", apply);
map.off("style.load", apply);
};
}, [isGlobeProjection]);
const handleZoomByStep = useCallback((delta: number) => {
const map = mapRef.current;
if (!map) return;
setZoomLevel((prev) => {
const next = clampNumber(prev + delta, zoomBounds.min, zoomBounds.max);
map.easeTo({ zoom: next, duration: 120 });
return next;
});
}, [zoomBounds]);
const handleZoomSliderChange = useCallback((nextRaw: number) => {
const map = mapRef.current;
if (!map || !Number.isFinite(nextRaw)) return;
const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max);
// Slider cần phản hồi trực tiếp theo pointer; easeTo liên tục sẽ làm thumb bị nhảy ngược.
map.jumpTo({ zoom: next });
setZoomLevel(next);
}, [zoomBounds]);
const beginZoomSliderDrag = useCallback(() => {
isZoomSliderDraggingRef.current = true;
}, []);
const endZoomSliderDrag = useCallback(() => {
const map = mapRef.current;
isZoomSliderDraggingRef.current = false;
if (!map) return;
setZoomLevel(roundZoom(map.getZoom()));
}, []);
const getViewState = useCallback(() => {
const map = mapRef.current;
if (!map) return null;
const center = map.getCenter();
const projection = map.getProjection();
return {
center: { lng: center.lng, lat: center.lat },
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
projection: String(projection?.type || "mercator"),
};
}, []);
return {
mapRef,
containerRef,
fatalInitError,
setFatalInitError,
zoomLevel,
zoomBounds,
isGlobeProjection,
setIsGlobeProjection,
isMapLoaded,
geolocationCenteredRef,
handleZoomByStep,
handleZoomSliderChange,
beginZoomSliderDrag,
endZoomSliderDrag,
getViewState,
};
}
+392
View File
@@ -0,0 +1,392 @@
import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import { initDrawing } from "@/uhm/lib/map/engines/drawingEngine";
import { initSelect } from "@/uhm/lib/map/engines/selectingEngine";
import { initPoint } from "@/uhm/lib/map/engines/pointEngine";
import { initLine } from "@/uhm/lib/map/engines/lineEngine";
import { initPath } from "@/uhm/lib/map/engines/pathEngine";
import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine";
import { FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
import { buildClientFeatureId } from "./mapUtils";
import type { MapFeaturePayload } from "../Map";
type EngineBinding = {
cleanup: () => void;
cancel?: () => void;
clearSelection?: (skipNotify?: boolean) => void;
syncSelection?: (ids: (string | number)[]) => void;
};
type UseMapInteractionProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
mode: EditorMode;
modeRef: React.MutableRefObject<EditorMode>;
// Rendered/interacted FeatureCollection from Map.tsx. This may already be filtered by
// replay/timeline state, so do not treat it as the canonical commit/edit draft.
renderDraftRef: React.MutableRefObject<FeatureCollection>;
allowGeometryEditing: boolean;
selectedFeatureIds: (string | number)[];
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
onSetModeRef: React.MutableRefObject<((mode: EditorMode, featureId?: string | number) => void) | undefined>;
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
onDeleteRef: React.MutableRefObject<((id: string | number | (string | number)[]) => void) | undefined>;
onHideRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
onFeatureClickRef: React.MutableRefObject<((payload: MapFeaturePayload | null) => void) | undefined>;
onBindGeometriesRef?: React.MutableRefObject<((targetId: string | number, sourceIds: (string | number)[]) => void) | undefined>;
localFeatureIdsRef?: React.MutableRefObject<(string | number)[] | undefined>;
onAddFeatureToProjectRef?: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
allowFeatureSelection?: boolean;
};
export function useMapInteraction({
mapRef,
mode,
modeRef,
renderDraftRef,
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
onSetModeRef,
onCreateRef,
onDeleteRef,
onHideRef,
onUpdateRef,
onFeatureClickRef,
onBindGeometriesRef,
localFeatureIdsRef,
onAddFeatureToProjectRef,
allowFeatureSelection = true,
}: UseMapInteractionProps) {
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
const previousModeRef = useRef<EditorMode>(mode);
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
const allowGeometryEditingRef = useRef(allowGeometryEditing);
const allowFeatureSelectionRef = useRef(allowFeatureSelection);
useEffect(() => {
allowGeometryEditingRef.current = allowGeometryEditing;
}, [allowGeometryEditing]);
useEffect(() => {
allowFeatureSelectionRef.current = allowFeatureSelection;
}, [allowFeatureSelection]);
useEffect(() => {
if (!editingEngineRef.current) {
editingEngineRef.current = createEditingEngine({
mapRef,
onUpdate: (id, geometry) => onUpdateRef.current?.(id, geometry),
});
}
}, [mapRef, onUpdateRef]);
useEffect(() => {
const allowsSelectionMode = mode === "select" || mode === "replay" || mode === "preview" || mode === "replay_preview";
if (!allowsSelectionMode || !selectedFeatureIds || selectedFeatureIds.length === 0) {
editingEngineRef.current?.clearEditing();
// Clear the internal selection state of the select engine to stay in sync with React state
engineBindingsRef.current.select?.clearSelection?.(false);
}
}, [mode, selectedFeatureIds]);
useEffect(() => {
const selectEngine = engineBindingsRef.current.select;
if (selectEngine?.syncSelection) {
selectEngine.syncSelection(selectedFeatureIds);
}
}, [selectedFeatureIds]);
useEffect(() => {
const previousMode = previousModeRef.current;
if (previousMode !== mode) {
engineBindingsRef.current[previousMode]?.cancel?.();
previousModeRef.current = mode;
}
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
if (mode !== "draw") {
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-line") {
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-path") {
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-circle") {
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
}, [mode, mapRef]);
const setupMapInteractions = (map: maplibregl.Map) => {
(map as MapWithRenderDraftRef)._renderDraftRef = renderDraftRef;
const drawingEngine = initDrawing(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "country",
geometry_preset: "polygon",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
bound_with: null,
},
geometry,
});
}
);
const selectEngine = initSelect(
map,
() => modeRef.current,
(id: string | number | (string | number)[]) => {
editingEngineRef.current?.clearEditing();
onSelectFeatureIdsRef.current?.([]);
onDeleteRef.current?.(id);
},
(feature) => {
const rawId = feature.properties?.id ?? feature.id;
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId)
);
editingEngineRef.current?.beginEditing(
(originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature
);
},
(id: string | number) => {
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(id)
);
if (!originalFeature) return;
const nextFeature = buildDuplicatedFeatureShapeOnly(originalFeature);
onCreateRef.current?.(nextFeature);
},
(id: string | number) => {
onHideRef.current?.(id);
onSelectFeatureIdsRef.current?.([]);
},
(ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id),
() => Boolean(editingEngineRef.current?.editingRef.current),
(targetId, sourceIds) => onBindGeometriesRef?.current?.(targetId, sourceIds),
(payload) => {
if (!payload) {
onFeatureClickRef.current?.(null);
return;
}
const currentFeature =
renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(payload.featureId)
) || null;
onFeatureClickRef.current?.({
...payload,
feature: currentFeature,
});
},
(feature) => {
if (!onAddFeatureToProjectRef?.current) return;
const rawId = feature.properties?.id ?? feature.id;
if (rawId === undefined || rawId === null) return;
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId)
);
if (!originalFeature) return;
onAddFeatureToProjectRef.current?.(originalFeature);
},
(id) => {
if (!onAddFeatureToProjectRef?.current) return true;
const localIds = localFeatureIdsRef?.current;
if (!Array.isArray(localIds)) return true;
return localIds.some((localId) => String(localId) === String(id));
},
() => allowFeatureSelectionRef.current,
() => allowGeometryEditingRef.current
);
const cleanupPoint = initPoint(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "city",
geometry_preset: "point",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
bound_with: null,
},
geometry,
});
}
);
const lineEngine = initLine(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "defense_line",
geometry_preset: "line",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
bound_with: null,
},
geometry,
});
}
);
const pathEngine = initPath(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "attack_route",
geometry_preset: "line",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
bound_with: null,
},
geometry,
});
}
);
const circleEngine = initCircle(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
id,
type: "war",
geometry_preset: "circle-area",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
bound_with: null,
},
geometry,
});
}
);
engineBindingsRef.current = {
draw: drawingEngine,
select: selectEngine,
preview: selectEngine,
replay: selectEngine,
replay_preview: selectEngine,
"add-line": lineEngine,
"add-path": pathEngine,
"add-circle": circleEngine,
};
mapCleanupFnsRef.current.push(
circleEngine.cleanup,
pathEngine.cleanup,
lineEngine.cleanup,
cleanupPoint,
selectEngine.cleanup,
drawingEngine.cleanup
);
const editCleanup = editingEngineRef.current?.bindEditEvents(map);
if (editCleanup) {
mapCleanupFnsRef.current.push(editCleanup);
}
};
const cleanupMapInteractions = () => {
for (const cleanupFn of mapCleanupFnsRef.current) {
cleanupFn();
}
mapCleanupFnsRef.current = [];
engineBindingsRef.current = {};
};
return {
editingEngineRef,
setupMapInteractions,
cleanupMapInteractions,
};
}
type MapWithRenderDraftRef = maplibregl.Map & {
_renderDraftRef?: React.MutableRefObject<FeatureCollection>;
};
function buildDuplicatedFeatureShapeOnly(
feature: FeatureCollection["features"][number]
): FeatureCollection["features"][number] {
const geometry = cloneGeometry(feature.geometry);
return {
type: "Feature",
properties: {
id: buildClientFeatureId(),
type: feature.properties.type ?? null,
geometry_preset: feature.properties.geometry_preset ?? null,
entity_id: null,
entity_ids: [],
entity_name: null,
entity_names: [],
bound_with: null,
},
geometry,
};
}
function cloneGeometry(geometry: Geometry): Geometry {
if (typeof structuredClone === "function") {
return structuredClone(geometry);
}
return JSON.parse(JSON.stringify(geometry)) as Geometry;
}
+289
View File
@@ -0,0 +1,289 @@
import maplibregl from "maplibre-gl";
import { GOONG_GLYPHS_PROXY_URL } from "@/uhm/api/config";
import { getGoongBackgroundOverlayBundle } from "@/uhm/api/tiles";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypeLayers";
import {
applyBackgroundLayerVisibility,
ensurePathArrowIcon,
} from "./mapUtils";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
export function getBaseMapStyle(): maplibregl.StyleSpecification {
return {
version: 8,
glyphs: GOONG_GLYPHS_PROXY_URL,
sources: {},
layers: [
{
id: "background",
type: "background",
paint: {
"background-color": "#0b1220",
},
},
],
};
}
export function setupMapLayers(
map: maplibregl.Map,
backgroundVisibility: BackgroundLayerVisibility
) {
applyBackgroundLayerVisibility(map, backgroundVisibility);
void replaceBackgroundLayersWithGoong(map, backgroundVisibility).catch((error) => {
console.error("Failed to load proxied background overlay bundle.", error);
});
const hasPathArrowIcon = ensurePathArrowIcon(map);
// preview (drawing)
map.addSource("draw-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-preview-fill",
type: "fill",
source: "draw-preview",
filter: ["==", ["get", "type"], "fill"],
paint: {
"fill-color": "#22c55e",
"fill-opacity": 0.4,
},
});
map.addLayer({
id: "draw-preview-line",
type: "line",
source: "draw-preview",
filter: ["!=", ["get", "type"], "fill"],
paint: {
"line-color": "#16a34a",
"line-width": 2,
},
});
map.addSource("draw-circle-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-circle-preview-fill",
type: "fill",
source: "draw-circle-preview",
paint: {
"fill-color": "#0ea5e9",
"fill-opacity": 0.25,
},
});
map.addLayer({
id: "draw-circle-preview-line",
type: "line",
source: "draw-circle-preview",
paint: {
"line-color": "#0284c7",
"line-width": 2,
"line-opacity": 0.95,
},
});
map.addSource("draw-line-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-line-preview-line",
type: "line",
source: "draw-line-preview",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
"line-opacity": 0.9,
"line-dasharray": [1.2, 0.9],
},
});
map.addSource("draw-path-preview", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "draw-path-preview-line",
type: "line",
source: "draw-path-preview",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
"line-opacity": 0.9,
"line-dasharray": [1.2, 0.9],
},
});
if (hasPathArrowIcon) {
map.addLayer({
id: "draw-path-preview-arrows",
type: "symbol",
source: "draw-path-preview",
layout: {
"symbol-placement": "line",
"symbol-spacing": 56,
"icon-image": PATH_ARROW_ICON_ID,
"icon-size": 0.45,
"icon-allow-overlap": true,
"icon-ignore-placement": true,
},
});
}
// data
map.addSource("countries", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
promoteId: "id",
});
map.addSource(PATH_ARROW_SOURCE_ID, {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION,
promoteId: "id",
});
map.addSource("places", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
promoteId: "id",
});
map.addSource(POLYGON_LABEL_SOURCE_ID, {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION,
promoteId: "id",
});
ensurePointGeotypeIcons(map);
const geotypeLayers = getAllGeotypeLayers("countries", PATH_ARROW_SOURCE_ID, "places");
for (const layer of geotypeLayers) {
map.addLayer(layer);
}
const geotypeLabelLayers = getAllGeotypeLabelLayers(POLYGON_LABEL_SOURCE_ID, "countries");
for (const layer of geotypeLabelLayers) {
map.addLayer(layer);
}
// editing overlays
map.addSource("edit-shape", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addSource("edit-handles", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
// Glowing halo under the edit shape line
map.addLayer({
id: "edit-shape-glow",
type: "line",
source: "edit-shape",
paint: {
"line-color": "#38bdf8",
"line-width": 8,
"line-opacity": 0.35,
"line-blur": 1.5,
},
});
map.addLayer({
id: "edit-shape-line",
type: "line",
source: "edit-shape",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
},
});
// Glowing halo under the edit handles
map.addLayer({
id: "edit-handles-glow",
type: "circle",
source: "edit-handles",
paint: {
"circle-color": [
"match",
["get", "status"],
"delete", "#ef4444",
"vertex", "#22c55e",
"edge", "#eab308",
"unknown", "#22c55e",
"#3b82f6" // default none
],
"circle-radius": 22,
"circle-opacity": 0.35,
"circle-blur": 0.85,
},
});
map.addLayer({
id: "edit-handles-circle",
type: "circle",
source: "edit-handles",
paint: {
"circle-color": [
"match",
["get", "status"],
"delete", "#ef4444",
"vertex", "#22c55e",
"edge", "#eab308",
"unknown", "#22c55e",
"#3b82f6" // default none
],
"circle-radius": 12,
"circle-stroke-color": [
"match",
["get", "status"],
"delete", "#7f1d1d",
"vertex", "#14532d",
"edge", "#713f12",
"unknown", "#14532d",
"#0f172a" // default none
],
"circle-stroke-width": 3,
},
});
}
async function replaceBackgroundLayersWithGoong(
map: maplibregl.Map,
backgroundVisibility: BackgroundLayerVisibility
) {
const bundle = await getGoongBackgroundOverlayBundle();
if (!bundle || map.getLayer("goong-country-labels-0")) {
return;
}
for (const [sourceId, source] of Object.entries(bundle.sources)) {
if (!map.getSource(sourceId)) {
map.addSource(sourceId, source);
}
}
const insertBeforeId = map.getLayer("draw-preview-fill")
? "draw-preview-fill"
: undefined;
for (const layer of bundle.layers) {
if (map.getLayer(layer.id)) continue;
map.addLayer(layer, insertBeforeId);
}
applyBackgroundLayerVisibility(map, backgroundVisibility);
}
+339
View File
@@ -0,0 +1,339 @@
import { useCallback, useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import { FEATURE_STATE_SOURCE_IDS, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
import {
applyBackgroundLayerVisibility,
buildPolygonLabelFeatureCollection,
buildPathArrowFeatureCollection,
decorateLineFeaturesWithLabels,
decoratePointFeaturesWithLabels,
filterDraftByBinding,
filterDraftByGeometryVisibility,
fitMapToFeatureCollection,
setSelectedFeatureState,
splitDraftFeatures,
decorateFeaturesWithEntityColors,
} from "./mapUtils";
import { applyImageOverlay, type MapImageOverlay } from "./imageOverlay";
type UseMapSyncProps = {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
// Already-filtered FeatureCollection that should be written to MapLibre sources.
// Timeline/replay filters must be applied before this hook receives it.
renderDraft: FeatureCollection;
// Lookup-only context for labels. It may contain geometries that are not rendered.
// Never use it to decide which geometries appear on the map.
labelContextDraft?: FeatureCollection;
labelTimelineYear?: number | null;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
applyGeometryBindingFilter: boolean;
fitToDraftBounds: boolean;
fitBoundsKey?: string | number | null;
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | maplibregl.PaddingOptions;
imageOverlay?: MapImageOverlay | null;
allowGeometryEditing: boolean;
editingEngineRef: React.MutableRefObject<{
editingRef: React.MutableRefObject<{ id: string | number } | null>;
clearEditing: () => void;
} | null>;
geolocationCenteredRef: React.MutableRefObject<boolean>;
isPreviewMode?: boolean;
};
export function useMapSync({
mapRef,
renderDraft,
labelContextDraft,
labelTimelineYear,
backgroundVisibility,
geometryVisibility,
selectedFeatureIds,
applyGeometryBindingFilter,
fitToDraftBounds,
fitBoundsKey,
focusFeatureCollection,
focusRequestKey,
focusPadding,
imageOverlay,
allowGeometryEditing,
editingEngineRef,
geolocationCenteredRef,
isPreviewMode,
}: UseMapSyncProps) {
const renderDraftRef = useRef<FeatureCollection>(renderDraft);
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
const labelTimelineYearRef = useRef<number | null | undefined>(labelTimelineYear);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
const applyGeometryBindingFilterRef = useRef(applyGeometryBindingFilter);
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
const imageOverlayRef = useRef<MapImageOverlay | null>(imageOverlay || null);
const focusFeatureCollectionRef = useRef<FeatureCollection | null | undefined>(focusFeatureCollection);
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
const isPreviewModeRef = useRef(isPreviewMode);
useEffect(() => {
renderDraftRef.current = renderDraft;
}, [renderDraft]);
useEffect(() => {
labelContextDraftRef.current = labelContextDraft;
}, [labelContextDraft]);
useEffect(() => {
labelTimelineYearRef.current = labelTimelineYear;
}, [labelTimelineYear]);
useEffect(() => {
backgroundVisibilityRef.current = backgroundVisibility;
}, [backgroundVisibility]);
useEffect(() => {
geometryVisibilityRef.current = geometryVisibility;
}, [geometryVisibility]);
useEffect(() => {
selectedFeatureIdsRef.current = selectedFeatureIds;
}, [selectedFeatureIds]);
useEffect(() => {
applyGeometryBindingFilterRef.current = applyGeometryBindingFilter;
}, [applyGeometryBindingFilter]);
useEffect(() => {
fitToDraftBoundsRef.current = fitToDraftBounds;
}, [fitToDraftBounds]);
useEffect(() => {
imageOverlayRef.current = imageOverlay || null;
}, [imageOverlay]);
useEffect(() => {
focusFeatureCollectionRef.current = focusFeatureCollection;
}, [focusFeatureCollection]);
useEffect(() => {
focusPaddingRef.current = focusPadding;
}, [focusPadding]);
useEffect(() => {
isPreviewModeRef.current = isPreviewMode;
}, [isPreviewMode]);
const fitBoundsAppliedRef = useRef(false);
const lastCountriesStrRef = useRef("");
const lastPlacesStrRef = useRef("");
const lastPolygonLabelStrRef = useRef("");
const lastPathArrowStrRef = useRef("");
useEffect(() => {
const map = mapRef.current;
if (map) {
(map as MapWithRenderDraftRef)._renderDraftRef = renderDraftRef;
}
}, [mapRef]);
useEffect(() => {
fitBoundsAppliedRef.current = false;
}, [fitBoundsKey]);
const applyRenderDraftToMap = useCallback((
renderFc: FeatureCollection,
labelContextOverride?: FeatureCollection,
selectedIdsOverride?: (string | number)[]
) => {
const map = mapRef.current;
if (!map) return;
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
const placesSource = map.getSource("places") as maplibregl.GeoJSONSource | undefined;
const polygonLabelSource = map.getSource(POLYGON_LABEL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (!countriesSource || !placesSource || !polygonLabelSource) return;
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
if (map.getSource(sourceId)) {
map.removeFeatureState({ source: sourceId });
}
}
const labelContext = labelContextOverride || labelContextDraftRef.current || renderFc;
const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current;
const bindingFilteredRenderDraft = applyGeometryBindingFilterRef.current
? filterDraftByBinding(renderFc, currentSelectedIds, null, isPreviewModeRef.current)
: renderFc;
const visibilityFilteredDraft = filterDraftByGeometryVisibility(bindingFilteredRenderDraft, geometryVisibilityRef.current);
const mapSourceDraft = decorateFeaturesWithEntityColors(visibilityFilteredDraft);
const labelTimelineYear = labelTimelineYearRef.current;
const { polygons, points } = splitDraftFeatures(mapSourceDraft);
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear);
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext, labelTimelineYear);
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext, labelTimelineYear);
const pathArrowShapes = buildPathArrowFeatureCollection(mapSourceDraft);
const countriesStr = JSON.stringify(labeledGeometries);
if (countriesStr !== lastCountriesStrRef.current) {
countriesSource.setData(labeledGeometries);
lastCountriesStrRef.current = countriesStr;
}
const placesStr = JSON.stringify(labeledPoints);
if (placesStr !== lastPlacesStrRef.current) {
placesSource.setData(labeledPoints);
lastPlacesStrRef.current = placesStr;
}
const polygonLabelsStr = JSON.stringify(polygonLabels);
if (polygonLabelsStr !== lastPolygonLabelStrRef.current) {
polygonLabelSource.setData(polygonLabels);
lastPolygonLabelStrRef.current = polygonLabelsStr;
}
const pathArrowSource = map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (pathArrowSource) {
const pathArrowStr = JSON.stringify(pathArrowShapes);
if (pathArrowStr !== lastPathArrowStrRef.current) {
pathArrowSource.setData(pathArrowShapes);
lastPathArrowStrRef.current = pathArrowStr;
}
}
currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true);
});
requestAnimationFrame(() => {
if (mapRef.current !== map) return;
currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true);
});
});
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, mapSourceDraft);
}
}, [mapRef]);
const tryCenterToUserLocation = useCallback(() => {
if (geolocationCenteredRef.current) return;
if (fitToDraftBoundsRef.current) return;
if (typeof window === "undefined") return;
// Nếu đã có tọa độ lưu từ phiên làm việc trước, không tự động dịch chuyển nữa
try {
if (window.localStorage.getItem("uhm:mapViewport")) {
geolocationCenteredRef.current = true;
return;
}
} catch {
// ignore
}
if (!("geolocation" in navigator)) return;
const map = mapRef.current;
if (!map) return;
geolocationCenteredRef.current = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
if (mapRef.current !== map) return;
const { longitude, latitude } = pos.coords;
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
const currentZoom = map.getZoom();
const nextZoom = Number.isFinite(currentZoom) ? Math.max(currentZoom, 5) : 5;
// Dùng jumpTo để teleport lập tức, loại bỏ hoạt ảnh trượt camera kéo dài
map.jumpTo({ center: [longitude, latitude], zoom: nextZoom });
},
() => { },
{ enableHighAccuracy: false, timeout: 4000, maximumAge: 60_000 }
);
}, [mapRef, geolocationCenteredRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyBackgroundLayerVisibility(map, backgroundVisibility);
}, [backgroundVisibility, mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyImageOverlay(map, imageOverlay);
}, [imageOverlay, mapRef]);
useEffect(() => {
applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds);
const editingId = editingEngineRef.current?.editingRef?.current?.id;
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = renderDraft.features.some((f) => String(f.properties.id) === String(editingId));
if (!stillExists) {
editingEngineRef.current?.clearEditing();
}
}
}, [
allowGeometryEditing,
renderDraft,
labelContextDraft,
labelTimelineYear,
selectedFeatureIds,
applyGeometryBindingFilter,
geometryVisibility,
applyRenderDraftToMap,
editingEngineRef,
]);
useEffect(() => {
if (focusRequestKey === null || focusRequestKey === undefined) return;
const map = mapRef.current;
const target = focusFeatureCollectionRef.current;
if (!target || !target.features.length) return;
if (!map) return;
let cancelled = false;
let rafId: number | null = null;
const focus = () => {
if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return;
fitMapToFeatureCollection(map, target, focusPaddingRef.current, {
duration: 550,
maxZoom: 10,
pointZoom: 9,
});
};
if (map.isStyleLoaded()) {
rafId = requestAnimationFrame(focus);
} else {
map.once("idle", focus);
}
return () => {
cancelled = true;
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [focusRequestKey, mapRef]);
return {
applyRenderDraftToMap,
tryCenterToUserLocation,
applyImageOverlayToMap: () => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyImageOverlay(map, imageOverlayRef.current);
},
};
}
type MapWithRenderDraftRef = maplibregl.Map & {
_renderDraftRef?: React.MutableRefObject<FeatureCollection>;
};
@@ -0,0 +1,170 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
const STORAGE_KEY = "uhm-public-first-guide-seen-v1";
type GuideItem = {
title: string;
details?: string[];
};
const guideItems: GuideItem[] = [
{
title: "Kéo bản đồ để di chuyển, cuộn để phóng to.",
},
{
title: "Dùng timeline phía dưới để chọn năm lịch sử.",
details: [
"Kéo chuột sang trái hoặc phải để điều chỉnh năm hiển thị.",
"Lăn chuột trên timeline để điều chỉnh phạm vi kéo.",
"Nhập trực tiếp năm vào ô số để đi nhanh tới mốc cần xem.",
"Range là phạm vi năm muốn hiển thị. Ví dụ time 1990 và range 5 sẽ hiển thị thông tin từ 1985 đến 1995.",
],
},
{
title: "Nhấn vào vùng, điểm hoặc đường trên bản đồ để mở wiki.",
details: [
"Có thể dùng Shift + lăn chuột để chọn thông tin chi tiết muốn tìm hiểu.",
],
},
{
title: "Bật/tắt lớp bản đồ ở bảng bên trái.",
details: [
"Có thể bật/tắt các đối tượng bản đồ tự nhiên lẫn lịch sử.",
],
},
{
title: "Nếu đối tượng có replay, nhấn nút phát để xem diễn biến.",
details: [
"Trong quá trình replay có thể dừng và tương tác như bình thường(những vì vấn đề kĩ thuật chúng tôi chưa thể làm việc đó mà không xảy ra lỗi, nên hiện tại chức năng replay bị hạn chế tương tác).",
],
},
{
title: "Sử dụng wiki để tìm kiếm thông tin liên quan.",
details: [
"Đối với các link trong wiki, màu xanh là đã có thông tin, màu đỏ là chưa có thông tin.",
"Chọn link bằng chuột trái để hiển thị wiki đích và bản đồ tự di chuyển đến khu vực liên quan.",
"Có thể nhấn chuột phải vào các link để có thêm lựa chọn khác.",
],
},
];
export default function FirstVisitGuideModal() {
const [isOpen, setIsOpen] = useState(() => shouldShowFirstVisitGuide());
const closeGuide = useCallback(() => {
try {
window.localStorage.setItem(STORAGE_KEY, "1");
} catch {
// Ignore storage failures; closing the modal should still work.
}
setIsOpen(false);
}, []);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeGuide();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [closeGuide, isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="first-visit-guide-title"
className="fixed inset-0 z-[80] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
>
<div className="flex max-h-[calc(100svh-3rem)] w-full max-w-2xl flex-col overflow-hidden rounded-lg border border-white/10 bg-white text-slate-950 shadow-2xl">
<div className="border-b border-slate-200 px-5 py-4 sm:px-6">
<p className="text-sm font-semibold uppercase tracking-wide text-blue-700">
Hướng dẫn nhanh
</p>
<h2 id="first-visit-guide-title" className="mt-1 text-2xl font-bold">
Chào mừng đến Ultimate History Map
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Một vài thao tác chính đ bạn bắt đu xem bản đ lịch sử, wiki replay.
</p>
</div>
<div className="overflow-y-auto px-5 py-4 sm:px-6">
<ol className="space-y-3">
{guideItems.map((item, index) => (
<li key={item.title} className="rounded-md border border-slate-200 bg-slate-50">
{item.details?.length ? (
<details className="group">
<summary className="flex cursor-pointer list-none items-start gap-3 px-4 py-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-900 text-sm font-bold text-white">
{index + 1}
</span>
<span className="min-w-0 flex-1 text-sm font-semibold leading-6 text-slate-900">
{item.title}
</span>
<span className="pt-0.5 text-xl leading-none text-blue-700 group-open:hidden">
+
</span>
<span className="hidden pt-0.5 text-xl leading-none text-blue-700 group-open:block">
-
</span>
</summary>
<ul className="space-y-2 border-t border-slate-200 px-4 py-3 pl-14">
{item.details.map((detail) => (
<li key={detail} className="list-disc text-sm leading-6 text-slate-700">
{detail}
</li>
))}
</ul>
</details>
) : (
<div className="flex items-start gap-3 px-4 py-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-900 text-sm font-bold text-white">
{index + 1}
</span>
<span className="text-sm font-semibold leading-6 text-slate-900">
{item.title}
</span>
</div>
)}
</li>
))}
</ol>
</div>
<div className="flex flex-col gap-3 border-t border-slate-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<Link
href="/faq"
onClick={closeGuide}
className="inline-flex h-10 items-center justify-center rounded-md border border-slate-300 px-4 text-sm font-semibold text-slate-700 transition hover:bg-slate-100"
>
Xem hướng dẫn chi tiết
</Link>
<button
type="button"
onClick={closeGuide}
className="inline-flex h-10 items-center justify-center rounded-md bg-slate-900 px-5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
Bắt đu khám phá
</button>
</div>
</div>
</div>
);
}
function shouldShowFirstVisitGuide(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(STORAGE_KEY) !== "1";
} catch {
return true;
}
}
@@ -0,0 +1,276 @@
"use client";
import { useEffect, useRef } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { FeatureCollection } from "@/uhm/types/geo";
export type GeometrySelectionGeometry = {
id: string;
center: [number, number] | null;
adminLabel: string | null;
adminAddress: string | null;
};
export type GeometrySelectionRow = {
entity: Entity;
geometries: GeometrySelectionGeometry[];
featureCollection: FeatureCollection;
};
type Props = {
wikiSlug: string;
rows: GeometrySelectionRow[];
isLoading: boolean;
error?: string | null;
onClose: () => void;
onSelectEntity: (entityId: string) => void;
};
export default function GeometrySelectionPanel({
wikiSlug,
rows,
isLoading,
error,
onClose,
onSelectEntity,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div
ref={containerRef}
style={{
width: "100%",
maxWidth: "100%",
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
overflow: "hidden",
borderRadius: 20,
border: "1px solid rgba(148, 163, 184, 0.22)",
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.85))",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)",
position: "relative",
}}
>
<div
style={{
borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
padding: "16px",
}}
>
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 12 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Hình bản đ
</div>
<div
style={{
marginTop: 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
color: "#f8fafc",
}}
>
Chọn thực thể đ phóng tới
</div>
{wikiSlug ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
/wiki/{wikiSlug}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
style={{
display: "inline-flex",
height: 28,
width: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
border: "1px solid rgba(148, 163, 184, 0.25)",
background: "rgba(30, 41, 59, 0.4)",
color: "#94a3b8",
cursor: "pointer",
fontSize: 12,
transition: "all 0.2s",
outline: "none",
}}
className="hover:bg-slate-700/50 hover:text-slate-100"
aria-label="Đóng bảng chọn hình bản đồ"
>
x
</button>
</div>
</div>
<div className="uhm-pinned-geometry-panel-scroll" style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16 }}>
{isLoading ? (
<div style={{ display: "grid", gap: 10 }}>
{[0, 1, 2].map((index) => (
<div
key={index}
style={{
height: index === 0 ? 64 : 82,
borderRadius: 10,
background: "rgba(148, 163, 184, 0.12)",
}}
className="animate-pulse"
/>
))}
</div>
) : error ? (
<div style={{ fontSize: 14, lineHeight: "20px", color: "#f87171" }}>
{error}
</div>
) : rows.length ? (
<div style={{ display: "grid", gap: 10 }}>
{rows.map(({ entity, geometries }, index) => {
const adminText = getGeometryAdminText(geometries);
return (
<div
key={entity.id}
style={{
paddingTop: index > 0 ? 12 : 0,
marginTop: index > 0 ? 4 : 0,
borderTop: index > 0 ? "1px solid rgba(148, 163, 184, 0.16)" : "none",
}}
>
<button
type="button"
onClick={() => onSelectEntity(entity.id)}
style={{
width: "100%",
padding: "9px 10px 9px 12px",
border: "1px solid transparent",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.34)",
boxShadow: "inset 2px 0 0 rgba(56, 189, 248, 0.52)",
textAlign: "left",
cursor: "pointer",
transition: "background 0.15s ease, border-color 0.15s ease",
}}
className="hover:border-sky-400/30 hover:bg-sky-500/10"
>
<div
style={{
fontSize: 14,
fontWeight: 800,
lineHeight: "20px",
color: "#f8fafc",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entity.name || String(entity.id)} {formatEntityYears(entity)}
</div>
{adminText ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflowWrap: "anywhere",
}}
>
{adminText}
</div>
) : entity.description?.trim() ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entity.description.trim()}
</div>
) : null}
</button>
</div>
);
})}
</div>
) : (
<div style={{ fontSize: 14, lineHeight: "20px", color: "#94a3b8" }}>
Wiki này chưa thực thể hoặc hình bản đ liên quan.
</div>
)}
</div>
<style jsx>{`
.uhm-pinned-geometry-panel-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(56, 189, 248, 0.58) rgba(15, 23, 42, 0.72);
}
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar {
width: 9px;
}
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.72);
}
.uhm-pinned-geometry-panel-scroll::-webkit-scrollbar-thumb {
border: 2px solid rgba(15, 23, 42, 0.95);
border-radius: 999px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.86), rgba(14, 165, 233, 0.58));
}
`}</style>
</div>
);
}
function formatEntityYears(entity: Entity): string {
const start = Number.isFinite(entity.time_start) ? String(entity.time_start) : "";
const end = Number.isFinite(entity.time_end) ? String(entity.time_end) : "";
if (!start && !end) return "";
return `(${start || "?"}-${end || "?"})`;
}
function getGeometryAdminText(geometries: GeometrySelectionGeometry[]): string {
const labels = geometries
.map((geometry) => geometry.adminLabel || geometry.adminAddress || "")
.map((label) => label.trim())
.filter((label) => label.length > 0);
return Array.from(new Set(labels)).join(" / ");
}
@@ -0,0 +1,151 @@
"use client";
import React from "react";
interface MapPlaceholderProps {
onEnter?: () => void;
}
export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
return (
<div
onClick={onEnter}
style={{
position: "fixed",
inset: 0,
backgroundColor: "#060a13",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
zIndex: 9999,
padding: "24px",
cursor: "pointer",
}}
>
{/* Background image under overlay */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/images/map_placeholder.webp"
alt="Nền bản đồ"
fetchPriority="high"
loading="eager"
decoding="sync"
width={1920}
height={1080}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
zIndex: 1,
transform: "scale(1.02)",
filter: "brightness(0.25) contrast(1.15) sepia(0.2)",
transition: "transform 10s ease-out",
backgroundImage: "url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQAgCdASoQAAkAAgA0JbACdAD0j9ruBiEAAP71e6Hb3PzxBzEI1XGXkdQ3Wq1ek8XLa1nPPm65FhrFIjmR0%2BxZwNUJBvg15I7CuzvhuunZ%2FUF83IaP8Evo6gAAAA%3D%3D')",
backgroundSize: "cover",
}}
/>
{/* Glowing background gradient lights - Warm Candle & Antique ambiance */}
<div
style={{
position: "absolute",
inset: 0,
zIndex: 2,
background: "radial-gradient(circle at 50% 50%, rgba(217, 119, 6, 0.05) 0%, rgba(6, 10, 19, 0.6) 60%, #060a13 100%)",
}}
/>
{/* Content Container (Antique, Luxurious serif typography) */}
<div
style={{
position: "relative",
zIndex: 3,
width: "100%",
maxWidth: "640px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "20px",
}}
>
{/* Title (Largest, gold/yellow color, antique Georgia font) */}
<h1
style={{
fontFamily: "Georgia, serif",
fontSize: "min(52px, 10vw)",
fontWeight: "normal",
letterSpacing: "0.02em",
color: "#f59e0b",
margin: 0,
lineHeight: 1.1,
textShadow: "0 0 20px rgba(245, 158, 11, 0.25), 0 2px 4px rgba(0, 0, 0, 0.8)",
textTransform: "uppercase",
}}
>
Ultimate History Map
</h1>
{/* Subtitle / Description (Right below title, italic, elegant, muted color) */}
<p
style={{
fontFamily: "Georgia, serif",
fontStyle: "italic",
fontSize: "min(16px, 4.5vw)",
color: "#94a3b8",
lineHeight: "1.7",
margin: 0,
maxWidth: "480px",
textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)",
}}
>
Hành trình khám phá biên giới, quốc gia các sự kiện lịch sử thế giới qua bản đ tương tác theo dòng thời gian.
</p>
</div>
{/* Bottom hint "nhấn vào chỗ bất kì để vào" with a slow breathing/fade pulse animation */}
<div
style={{
position: "absolute",
bottom: "50px",
left: "50%",
transform: "translateX(-50%)",
zIndex: 4,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "8px",
pointerEvents: "none",
}}
>
<div
style={{
fontFamily: "Georgia, serif",
fontSize: "12px",
fontWeight: "normal",
letterSpacing: "0.2em",
color: "#d97706",
textTransform: "uppercase",
opacity: 0.8,
animation: "placeholder-pulse 2s ease-in-out infinite",
textShadow: "0 0 8px rgba(217, 119, 6, 0.4)",
}}
>
nhấn vào chỗ bất đ vào
</div>
<style dangerouslySetInnerHTML={{
__html: `
@keyframes placeholder-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.98); }
50% { opacity: 0.95; transform: scale(1); }
}
`}} />
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,476 @@
"use client";
import Image from "next/image";
import { type CSSProperties, type ReactNode, useState, useEffect } from "react";
import { apiGetCurrentUser } from "@/service/auth";
import ChatbotWidget from "@/uhm/components/ui/ChatbotWidget";
import Map, { type MapFeaturePayload } from "@/uhm/components/Map";
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
import TimelineBar from "@/uhm/components/ui/TimelineBar";
import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
import type { BackgroundLayerId, BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import type { FeatureCollection } from "@/uhm/types/geo";
import type { Feature } from "@/uhm/lib/editor/state/useEditorState";
type Props = {
renderDraft: FeatureCollection;
labelContextDraft: FeatureCollection;
labelTimelineYear?: number | null;
selectedFeatureIds: (string | number)[];
onSelectFeatureIds: (ids: (string | number)[]) => void;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility: Record<string, boolean>;
onToggleBackground: (id: BackgroundLayerId) => void;
onToggleGeometry: (typeKey: string) => void;
timelineYear: number;
onTimelineYearChange: (year: number) => void;
timelineTimeRange?: number;
onTimelineTimeRangeChange?: (range: number) => void;
timelineFilterEnabled?: boolean;
onTimelineFilterEnabledChange?: (enabled: boolean) => void;
isTimelineLoading: boolean;
timelineDisabled?: boolean;
timelineStatusText?: string | null;
timelineStyle?: CSSProperties;
onFeatureClick?: (payload: MapFeaturePayload | null) => void;
hoverPopupEnabled?: boolean;
getHoverPopupContent?: (feature: Feature) => MapHoverPopupContent | null;
onHoverFeatureChange?: (feature: Feature | null) => void;
activeEntity?: Entity | null;
activeWiki?: Wiki | null;
isWikiLoading?: boolean;
wikiError?: string | null;
onCloseWikiSidebar?: () => void;
onWikiLinkRequest?: (request: { slug: string; rect: DOMRect }) => void;
onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void;
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxSidebarDragWidth?: number;
onPlayPreviewReplay?: () => void;
mapHandleRef?: React.RefObject<import("@/uhm/components/Map").MapHandle | null>;
overlay?: ReactNode;
children?: ReactNode;
onLoad?: () => void;
instantLoad?: boolean;
onToggleInstantLoad?: (val: boolean) => void;
isLayerPanelVisible?: boolean;
onLayerPanelVisibleChange?: (visible: boolean) => void;
sidebarHeight?: number;
onSidebarHeightChange?: (height: number) => void;
showViewportControls?: boolean;
hasAnyBottomPanel?: boolean;
};
export default function PreviewMapShell({
renderDraft,
labelContextDraft,
labelTimelineYear,
selectedFeatureIds,
onSelectFeatureIds,
backgroundVisibility,
geometryVisibility,
onToggleBackground,
onToggleGeometry,
timelineYear,
onTimelineYearChange,
timelineTimeRange,
onTimelineTimeRangeChange,
timelineFilterEnabled,
onTimelineFilterEnabledChange,
isTimelineLoading,
timelineDisabled = false,
timelineStatusText = null,
timelineStyle,
onFeatureClick,
hoverPopupEnabled = false,
getHoverPopupContent,
onHoverFeatureChange,
activeEntity = null,
activeWiki = null,
isWikiLoading = false,
wikiError = null,
onCloseWikiSidebar,
onWikiLinkRequest,
onWikiLinkEntitySelectionRequest,
sidebarWidth,
onSidebarWidthChange,
maxSidebarDragWidth,
onPlayPreviewReplay,
mapHandleRef,
overlay,
children,
onLoad,
instantLoad = true,
onToggleInstantLoad,
isLayerPanelVisible: propsLayerPanelVisible,
onLayerPanelVisibleChange,
sidebarHeight,
onSidebarHeightChange,
showViewportControls = true,
hasAnyBottomPanel = false,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [localLayerPanelVisible, setLocalLayerPanelVisible] = useState(true);
const isLayerPanelVisible = propsLayerPanelVisible ?? localLayerPanelVisible;
const setIsLayerPanelVisible = onLayerPanelVisibleChange ?? setLocalLayerPanelVisible;
const [isMobileOrTablet, setIsMobileOrTablet] = useState(false);
useEffect(() => {
const checkDevice = () => setIsMobileOrTablet(window.innerWidth < 1024);
checkDevice();
window.addEventListener("resize", checkDevice);
return () => window.removeEventListener("resize", checkDevice);
}, []);
useEffect(() => {
const fetchUserAvatar = async () => {
try {
const userData = await apiGetCurrentUser();
const nextAvatarUrl = getCurrentUserAvatarUrl(userData);
if (nextAvatarUrl) setAvatarUrl(nextAvatarUrl);
} catch {
// Guest preview does not need an authenticated profile.
}
};
fetchUserAvatar();
}, []);
const hasWikiSidebar = Boolean(activeEntity || activeWiki || isWikiLoading || wikiError);
const hasBottomPanel = hasWikiSidebar || hasAnyBottomPanel;
const menuOptionStyle: CSSProperties = {
width: 46,
height: 46,
backgroundColor: "#1e293b",
color: "#cbd5e1",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.12)",
backdropFilter: "blur(6px)",
};
return (
<div className="relative overflow-hidden bg-gray-950 text-gray-100" style={{ minHeight: "100svh", height: "100svh" }}>
<div className="relative" style={{ minHeight: "100svh", height: "100svh" }}>
<Map
ref={mapHandleRef}
mode="preview"
renderDraft={renderDraft}
labelContextDraft={labelContextDraft}
labelTimelineYear={labelTimelineYear}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={onSelectFeatureIds}
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
allowGeometryEditing={false}
allowFeatureSelection
applyGeometryBindingFilter
isPreviewMode
onFeatureClick={onFeatureClick}
hoverPopupEnabled={hoverPopupEnabled && !isMobileOrTablet}
getHoverPopupContent={getHoverPopupContent}
onHoverFeatureChange={onHoverFeatureChange}
onPlayPreviewReplay={onPlayPreviewReplay}
onLoad={onLoad}
showViewportControls={showViewportControls && !isMobileOrTablet}
height="100svh"
/>
<TimelineBar
year={timelineYear}
onYearChange={onTimelineYearChange}
timeRange={timelineTimeRange}
onTimeRangeChange={onTimelineTimeRangeChange}
isLoading={isTimelineLoading}
disabled={timelineDisabled}
statusText={timelineStatusText}
filterEnabled={timelineFilterEnabled}
onFilterEnabledChange={onTimelineFilterEnabledChange}
style={timelineStyle}
onPlayReplay={onPlayPreviewReplay}
/>
<style dangerouslySetInnerHTML={{ __html: `
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}} />
<aside
style={{
position: "absolute",
top: 10,
bottom: (hasBottomPanel && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20,
left: 18,
zIndex: 18,
display: "flex",
flexDirection: "column",
gap: 12,
width: 58,
pointerEvents: "none",
transition: "bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "center",
pointerEvents: "auto",
}}
>
<button
type="button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
title={isMenuOpen ? "Đóng cài đặt" : "Tham gia hệ thống / Trợ giúp"}
aria-label="Cài đặt"
style={{
width: 46,
height: 46,
backgroundColor: "#1e293b",
border: "1px solid rgba(255, 255, 255, 0.15)",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
backdropFilter: "blur(8px)",
flexShrink: 0,
overflow: "hidden",
padding: 0,
}}
>
{avatarUrl ? (
<Image
src={avatarUrl}
alt="Cài đặt"
width={46}
height={46}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#cbd5e1"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transition: "color 0.2s ease" }}
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
)}
</button>
{isMenuOpen && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "center",
animation: "slideDown 0.2s ease-out",
}}
>
<button
type="button"
onClick={() => {
if (isMobileOrTablet) {
alert("Tính năng quản trị và chỉnh sửa chỉ hỗ trợ trên máy tính.");
} else {
window.location.href = "/user";
}
}}
title={isMobileOrTablet ? "Tính năng này chỉ hoạt động trên máy tính" : "Quản trị và chỉnh sửa"}
style={{
...menuOptionStyle,
opacity: isMobileOrTablet ? 0.5 : 1,
cursor: isMobileOrTablet ? "not-allowed" : "pointer",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>
<button
type="button"
onClick={() => onToggleInstantLoad?.(!instantLoad)}
title="Bật lên để load nhanh hơn"
style={{
...menuOptionStyle,
color: instantLoad ? "#fbbf24" : "#cbd5e1",
border: instantLoad ? "1px solid rgba(251, 191, 36, 0.4)" : "1px solid rgba(255, 255, 255, 0.08)",
boxShadow: instantLoad ? "0 0 12px rgba(251, 191, 36, 0.25)" : "0 2px 8px rgba(0, 0, 0, 0.12)",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill={instantLoad ? "#fbbf24" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
</button>
<button
type="button"
onClick={() => {
window.dispatchEvent(new CustomEvent("toggle-chatbot"));
}}
title="Trợ lý AI Lịch sử (Chatbot)"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
type="button"
onClick={() => { window.location.href = "/faq"; }}
title="Hỏi đáp và hướng dẫn"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
</button>
<button
type="button"
onClick={() => { window.location.href = "/about-us"; }}
title="Về chúng tôi"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
{!isLayerPanelVisible && (
<button
type="button"
onClick={() => setIsLayerPanelVisible(true)}
title="Hiện bảng lớp bản đồ"
style={{
...menuOptionStyle,
color: "#10b981",
border: "1px solid rgba(16, 185, 129, 0.3)",
boxShadow: "0 2px 8px rgba(16, 185, 129, 0.15)",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
)}
</div>
)}
</div>
{isLayerPanelVisible && (
<div
style={{
flexGrow: 1,
flexShrink: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
pointerEvents: "auto",
}}
>
<ReplayPreviewLayerPanel
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
onToggleBackground={onToggleBackground}
onToggleGeometry={onToggleGeometry}
onHide={() => setIsLayerPanelVisible(false)}
/>
</div>
)}
</aside>
{overlay}
{hasWikiSidebar ? (
<aside
className={isMobileOrTablet ? "uhm-public-wiki-sidebar" : "uhm-public-wiki-sidebar absolute bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]"}
style={isMobileOrTablet ? {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
top: "auto",
height: `${sidebarHeight || 400}px`,
maxHeight: "90vh",
width: "100%",
maxWidth: "100%",
zIndex: 20,
// Do not transition height during drag resizing for butter smoothness
} : {
width: `min(${sidebarWidth || 420}px, calc(100vw - 2rem))`,
}}
>
<PublicWikiSidebar
entity={activeEntity}
wiki={activeWiki}
isLoading={isWikiLoading}
error={wikiError}
onClose={onCloseWikiSidebar || (() => {})}
onWikiLinkRequest={onWikiLinkRequest || (() => {})}
onWikiLinkEntitySelectionRequest={onWikiLinkEntitySelectionRequest}
sidebarWidth={sidebarWidth}
onSidebarWidthChange={onSidebarWidthChange}
maxDragWidth={maxSidebarDragWidth}
compactHeader
sidebarHeight={sidebarHeight}
onSidebarHeightChange={onSidebarHeightChange}
/>
</aside>
) : null}
<ChatbotWidget hideFloatingButton />
{children}
</div>
</div>
);
}
function getCurrentUserAvatarUrl(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const data = (value as { data?: unknown }).data;
if (!data || typeof data !== "object") return null;
const profile = (data as { profile?: unknown }).profile;
if (!profile || typeof profile !== "object") return null;
const avatarUrl = (profile as { avatar_url?: unknown }).avatar_url;
return typeof avatarUrl === "string" && avatarUrl.trim() ? avatarUrl : null;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
"use client";
export default function PublicPreviewWrapper() {
return null;
}
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef } from "react";
import type { Entity } from "@/uhm/api/entities";
type Props = {
slug: string;
entities: Entity[];
top: number;
left: number;
onClose: () => void;
onSelectEntity: (entityId: string) => void;
};
export default function RelatedEntityPopup({
slug,
entities,
top,
left,
onClose,
onSelectEntity,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && containerRef.current?.contains(target)) {
return;
}
onClose();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("pointerdown", handlePointerDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("pointerdown", handlePointerDown);
};
}, [onClose]);
return (
<div
ref={containerRef}
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
style={{ top, left }}
>
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Related Entities
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
/wiki/{slug}
</div>
</div>
<div className="max-h-[220px] overflow-y-auto p-2">
<div className="grid gap-1">
{entities.map((entity) => (
<button
key={entity.id}
type="button"
onClick={() => {
onSelectEntity(entity.id);
}}
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
>
{entity.name}
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,230 @@
"use client";
import { useEffect, useRef } from "react";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
type WikiSelectionRow = {
entity: Entity;
wiki: Wiki;
quote: string;
};
type Props = {
rows: WikiSelectionRow[];
onClose: () => void;
onSelectRow: (entityId: string, wikiId: string) => void;
};
export default function WikiSelectionPanel({
rows,
onClose,
onSelectRow,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div
ref={containerRef}
style={{
width: "100%",
maxWidth: "100%",
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
overflow: "hidden",
borderRadius: 20,
border: "1px solid rgba(148, 163, 184, 0.22)",
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.85))",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)",
position: "relative",
}}
>
<div
style={{
borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
padding: "16px",
}}
>
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 12 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Wiki
</div>
<div
style={{
marginTop: 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
color: "#f8fafc",
}}
>
Chọn wiki đ mở
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
display: "inline-flex",
height: 28,
width: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
border: "1px solid rgba(148, 163, 184, 0.25)",
background: "rgba(30, 41, 59, 0.4)",
color: "#94a3b8",
cursor: "pointer",
fontSize: 12,
transition: "all 0.2s",
outline: "none",
}}
className="hover:bg-slate-700/50 hover:text-slate-100"
aria-label="Đóng bảng chọn wiki"
>
x
</button>
</div>
</div>
<div className="uhm-pinned-wiki-panel-scroll" style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 16 }}>
<div style={{ display: "grid", gap: 10 }}>
{rows.map(({ entity, wiki, quote }, index) => {
const previous = rows[index - 1];
const startsEntityGroup = !previous || previous.entity.id !== entity.id;
return (
<div key={`${entity.id}:${wiki.id}`}>
{startsEntityGroup ? (
<div
style={{
paddingTop: index > 0 ? 12 : 0,
marginTop: index > 0 ? 4 : 0,
borderTop: index > 0 ? "1px solid rgba(148, 163, 184, 0.16)" : "none",
}}
>
<div style={{ fontSize: 14, fontWeight: 800, color: "#f8fafc", lineHeight: "20px" }}>
{entity.name || String(entity.id)}
</div>
{entity.description?.trim() ? (
<div
style={{
marginTop: 4,
fontSize: 12,
lineHeight: "17px",
color: "#94a3b8",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entity.description.trim()}
</div>
) : null}
</div>
) : null}
<button
type="button"
onClick={() => onSelectRow(entity.id, wiki.id)}
style={{
width: "100%",
marginTop: 6,
padding: "9px 10px 9px 12px",
border: "1px solid transparent",
borderRadius: 10,
background: "rgba(15, 23, 42, 0.34)",
boxShadow: "inset 2px 0 0 rgba(56, 189, 248, 0.52)",
textAlign: "left",
cursor: "pointer",
transition: "background 0.15s ease, border-color 0.15s ease",
}}
className="hover:border-sky-400/30 hover:bg-sky-500/10"
>
<div
style={{
fontSize: 13,
fontWeight: 800,
lineHeight: "19px",
color: "#e2e8f0",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
}}
>
{wiki.title?.trim() || entity.name || String(wiki.id)}
</div>
{quote ? (
<div
style={{
marginTop: 6,
paddingLeft: 10,
borderLeft: "2px solid rgba(56, 189, 248, 0.48)",
fontSize: 12.5,
fontStyle: "italic",
lineHeight: "18px",
color: "#cbd5e1",
display: "-webkit-box",
WebkitLineClamp: 4,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "normal",
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
>
{quote}
</div>
) : null}
</button>
</div>
);
})}
</div>
</div>
<style jsx>{`
.uhm-pinned-wiki-panel-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(56, 189, 248, 0.58) rgba(15, 23, 42, 0.72);
}
.uhm-pinned-wiki-panel-scroll::-webkit-scrollbar {
width: 9px;
}
.uhm-pinned-wiki-panel-scroll::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.72);
}
.uhm-pinned-wiki-panel-scroll::-webkit-scrollbar-thumb {
border: 2px solid rgba(15, 23, 42, 0.95);
border-radius: 999px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.86), rgba(14, 165, 233, 0.58));
}
`}</style>
</div>
);
}
@@ -0,0 +1,461 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchGeometriesByBBox, fetchGeometriesByBoundWith } from "@/uhm/api/geometries";
import { ApiError } from "@/uhm/api/http";
import {
fetchEntitiesByGeometryIds,
fetchWikisByEntityIdsWithPreviews,
} from "@/uhm/api/relations";
import type { Wiki } from "@/uhm/api/wikis";
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/map/geo/constants";
import {
buildEntityLabelContextDraft,
buildPublicPreviewRelationIndex,
} from "@/uhm/lib/preview/relationIndex";
import {
EMPTY_PREVIEW_RELATIONS,
type PreviewRelationIndex,
} from "@/uhm/lib/preview/types";
import type { Entity } from "@/uhm/types/entities";
import type { FeatureCollection, FeatureEntityPreview, FeatureWikiPreview } from "@/uhm/types/geo";
import type { BattleReplay } from "@/uhm/types/projects";
import { fetchBattleReplaysByGeometryIds } from "@/uhm/api/battleReplays";
export function usePublicPreviewData(options: {
timelineYear: number;
timeRange: number;
enabled?: boolean;
}) {
const { timelineYear, timeRange, enabled = true } = options;
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
const [relations, setRelations] = useState<PreviewRelationIndex>(EMPTY_PREVIEW_RELATIONS);
const [replays, setReplays] = useState<BattleReplay[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
const [isRelationsLoading, setIsRelationsLoading] = useState(false);
const [relationsStatus, setRelationsStatus] = useState<string | null>(null);
const timelineFetchRequestRef = useRef(0);
const loadedChildGeometryParentIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!enabled) {
setData(EMPTY_FEATURE_COLLECTION);
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
setIsTimelineLoading(false);
setIsRelationsLoading(false);
setTimelineStatus(null);
setRelationsStatus(null);
loadedChildGeometryParentIdsRef.current.clear();
return;
}
let disposed = false;
const requestId = ++timelineFetchRequestRef.current;
async function loadByTimeline() {
setIsTimelineLoading(true);
setIsRelationsLoading(false);
setTimelineStatus(null);
setRelationsStatus(null);
loadedChildGeometryParentIdsRef.current.clear();
let next: FeatureCollection;
try {
next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange, hasBound: false });
if (disposed || requestId !== timelineFetchRequestRef.current) return;
} catch (err) {
if (err instanceof ApiError) {
console.error("Load public map geometries failed", err.body);
} else {
console.error("Load public map geometries failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setData(EMPTY_FEATURE_COLLECTION);
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
setTimelineStatus("Không tải được dữ liệu bản đồ tại mốc thời gian đã chọn.");
}
return;
} finally {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setIsTimelineLoading(false);
}
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const geometryIds = next.features
.map((feature) => String(feature.properties.id))
.filter((id) => Boolean(id) && UUID_REGEX.test(id));
if (!geometryIds.length) {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setData(next);
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
}
return;
}
setIsRelationsLoading(true);
setRelationsStatus("Đang nạp liên kết entity/wiki.");
let entitiesByGeometryId: Record<string, Entity[]>;
let fetchedReplays: BattleReplay[] = [];
const geometryIdsWithReplays = getGeometryIdsWithReplays(next);
try {
const [entities, replaysRes] = await Promise.all([
fetchEntitiesByGeometryIds(geometryIds),
geometryIdsWithReplays.length
? fetchBattleReplaysByGeometryIds(geometryIdsWithReplays).catch((err) => {
console.error("Failed to load replays:", err);
return {};
})
: Promise.resolve({}),
]);
entitiesByGeometryId = entities;
const uniqueReplaysMap = new Map<string, BattleReplay>();
for (const list of Object.values(replaysRes)) {
for (const item of list || []) {
if (item && item.id) {
uniqueReplaysMap.set(item.id, item);
}
}
}
fetchedReplays = Array.from(uniqueReplaysMap.values());
if (disposed || requestId !== timelineFetchRequestRef.current) return;
} catch (err) {
if (err instanceof ApiError) {
console.error("Load public map geometry-entity relations failed", err.body);
} else {
console.error("Load public map geometry-entity relations failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setData(next); // Fallback to new geometry even if relations failed
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ.");
setIsRelationsLoading(false);
}
return;
}
const entityIds = uniqueStrings(
Object.values(entitiesByGeometryId)
.flat()
.map((entity) => entity.id)
);
let wikisByEntityId: Record<string, Wiki[]> = {};
if (entityIds.length) {
try {
wikisByEntityId = await fetchWikisByEntityIdsWithPreviews(entityIds);
if (disposed || requestId !== timelineFetchRequestRef.current) return;
} catch (err) {
if (err instanceof ApiError) {
console.error("Load initial entity-wiki previews failed", err.body);
} else {
console.error("Load initial entity-wiki previews failed", err);
}
}
}
const entityOnlyRelations = buildPublicPreviewRelationIndex(
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, wikisByEntityId)
);
// Apply BOTH geometries and relations at the exact same React render cycle!
setData(next);
setRelations(entityOnlyRelations);
setReplays(fetchedReplays);
// Mark loading as complete immediately so map transitions and becomes interactive
setIsRelationsLoading(false);
setRelationsStatus(null);
}
loadByTimeline();
return () => {
disposed = true;
};
}, [timelineYear, timeRange, enabled]);
const labelContextDraft = useMemo(
() => buildEntityLabelContextDraft(data, relations),
[data, relations]
);
const ensureChildrenForGeometry = useCallback(async (parentGeometryId: string | number | null | undefined) => {
const parentId = String(parentGeometryId || "").trim();
if (!parentId || loadedChildGeometryParentIdsRef.current.has(parentId)) return;
loadedChildGeometryParentIdsRef.current.add(parentId);
let childFc: FeatureCollection;
try {
childFc = await fetchGeometriesByBoundWith(parentId);
} catch (err) {
loadedChildGeometryParentIdsRef.current.delete(parentId);
console.error("Load child geometries failed", err);
return;
}
const childGeometryIds = uniqueStrings(childFc.features.map((feature) => String(feature.properties.id || "")));
if (!childGeometryIds.length) return;
const childGeometryIdsWithReplays = getGeometryIdsWithReplays(childFc);
setData((prev) => mergeFeatureCollections(prev, childFc));
let entitiesByGeometryId: Record<string, Entity[]> = {};
let wikisByEntityId: Record<string, Wiki[]> = {};
try {
entitiesByGeometryId = await fetchEntitiesByGeometryIds(childGeometryIds);
const entityIds = uniqueStrings(
Object.values(entitiesByGeometryId)
.flat()
.map((entity) => entity.id)
);
if (entityIds.length) {
wikisByEntityId = await fetchWikisByEntityIdsWithPreviews(entityIds);
}
} catch (err) {
console.error("Load child geometry relations failed", err);
}
const childRelations = buildPublicPreviewRelationIndex(
buildRelationInputFromGeometryRelations(childFc, entitiesByGeometryId, wikisByEntityId)
);
setRelations((prev) => mergePreviewRelationIndexes(prev, childRelations));
try {
if (!childGeometryIdsWithReplays.length) return;
const replayRows = await fetchBattleReplaysByGeometryIds(childGeometryIdsWithReplays);
const childReplays = Object.values(replayRows).flat();
if (childReplays.length) {
setReplays((prev) => mergeReplays(prev, childReplays));
}
} catch (err) {
console.error("Load child geometry replays failed", err);
}
}, []);
return {
data,
renderDraft: labelContextDraft,
labelContextDraft,
relations,
setRelations,
isTimelineLoading,
timelineStatus,
isRelationsLoading,
relationsStatus,
replays,
ensureChildrenForGeometry,
};
}
function buildRelationInputFromGeometryRelations(
draft: FeatureCollection,
entitiesByGeometryId: Record<string, Entity[]>,
wikisByEntityId: Record<string, Wiki[]>
): {
entities: Entity[];
entityGeometriesById: Record<string, FeatureCollection>;
entityWikisById: Record<string, Wiki[]>;
} {
const entitiesById: Record<string, Entity> = {};
const entityGeometriesById: Record<string, FeatureCollection> = {};
const mergedWikisByEntityId: Record<string, Wiki[]> = {};
for (const feature of draft.features) {
const geometryId = String(feature.properties.id);
const embeddedEntities = Array.isArray(feature.properties.public_entity_previews)
? feature.properties.public_entity_previews
: [];
for (const entity of entitiesByGeometryId[geometryId] || []) {
const id = String(entity?.id || "").trim();
if (!id) continue;
entitiesById[id] = entity;
pushFeature(entityGeometriesById, id, feature);
}
for (const entityPreview of embeddedEntities) {
const entity = featureEntityPreviewToEntity(entityPreview);
if (!entity) continue;
entitiesById[entity.id] = {
...entity,
...entitiesById[entity.id],
};
pushFeature(entityGeometriesById, entity.id, feature);
pushWikis(
mergedWikisByEntityId,
entity.id,
(entityPreview.wikis || []).map(featureWikiPreviewToWiki).filter((wiki): wiki is Wiki => Boolean(wiki))
);
}
}
for (const [entityId, wikis] of Object.entries(wikisByEntityId || {})) {
pushWikis(mergedWikisByEntityId, entityId, wikis || []);
}
return {
entities: Object.values(entitiesById),
entityGeometriesById,
entityWikisById: mergedWikisByEntityId,
};
}
function featureEntityPreviewToEntity(preview: FeatureEntityPreview): Entity | null {
const id = String(preview?.id || "").trim();
if (!id) return null;
return {
id,
name: String(preview.name || id).trim() || id,
description: preview.description ?? null,
time_start: preview.time_start ?? null,
time_end: preview.time_end ?? null,
};
}
function featureWikiPreviewToWiki(preview: FeatureWikiPreview): Wiki | null {
const id = String(preview?.id || "").trim();
if (!id) return null;
return {
id,
project_id: "",
title: preview.title || undefined,
slug: preview.slug ?? null,
content: preview.content || "",
preview_quote: preview.preview_quote ?? null,
};
}
function pushWikis(target: Record<string, Wiki[]>, entityId: string, wikis: Wiki[]) {
const id = String(entityId || "").trim();
if (!id) return;
if (!target[id]) target[id] = [];
for (const wiki of wikis || []) {
if (!wiki?.id) continue;
const existingIndex = target[id].findIndex((item) => item.id === wiki.id);
if (existingIndex >= 0) {
target[id][existingIndex] = {
...target[id][existingIndex],
...wiki,
};
} else {
target[id].push(wiki);
}
}
}
function pushFeature(target: Record<string, FeatureCollection>, entityId: string, feature: FeatureCollection["features"][number]) {
if (!target[entityId]) target[entityId] = { type: "FeatureCollection", features: [] };
if (!target[entityId].features.some((item) => String(item.properties.id) === String(feature.properties.id))) {
target[entityId].features.push(feature);
}
}
function mergeFeatureCollections(base: FeatureCollection, incoming: FeatureCollection): FeatureCollection {
const byId = new Map<string, FeatureCollection["features"][number]>();
for (const feature of base.features || []) {
byId.set(String(feature.properties.id), feature);
}
for (const feature of incoming.features || []) {
byId.set(String(feature.properties.id), feature);
}
return {
type: "FeatureCollection",
features: Array.from(byId.values()),
};
}
function mergePreviewRelationIndexes(base: PreviewRelationIndex, incoming: PreviewRelationIndex): PreviewRelationIndex {
return {
entitiesById: {
...base.entitiesById,
...incoming.entitiesById,
},
entityGeometriesById: mergeFeatureCollectionRecords(base.entityGeometriesById, incoming.entityGeometriesById),
entityWikisById: mergeWikiRecords(base.entityWikisById, incoming.entityWikisById),
geometryEntityIds: mergeStringArrayRecords(base.geometryEntityIds, incoming.geometryEntityIds),
wikiEntityIdsById: mergeStringArrayRecords(base.wikiEntityIdsById, incoming.wikiEntityIdsById),
wikiEntityIdsBySlug: mergeStringArrayRecords(base.wikiEntityIdsBySlug, incoming.wikiEntityIdsBySlug),
wikiById: {
...base.wikiById,
...incoming.wikiById,
},
wikiBySlug: {
...base.wikiBySlug,
...incoming.wikiBySlug,
},
};
}
function mergeFeatureCollectionRecords(
base: Record<string, FeatureCollection>,
incoming: Record<string, FeatureCollection>
): Record<string, FeatureCollection> {
const next = { ...base };
for (const [entityId, fc] of Object.entries(incoming || {})) {
next[entityId] = next[entityId] ? mergeFeatureCollections(next[entityId], fc) : fc;
}
return next;
}
function mergeWikiRecords(base: Record<string, Wiki[]>, incoming: Record<string, Wiki[]>): Record<string, Wiki[]> {
const next: Record<string, Wiki[]> = { ...base };
for (const [entityId, wikis] of Object.entries(incoming || {})) {
next[entityId] = mergeWikis(next[entityId] || [], wikis || []);
}
return next;
}
function mergeWikis(base: Wiki[], incoming: Wiki[]): Wiki[] {
const byId = new Map<string, Wiki>();
for (const wiki of base || []) {
if (wiki?.id) byId.set(wiki.id, wiki);
}
for (const wiki of incoming || []) {
if (wiki?.id) {
byId.set(wiki.id, {
...byId.get(wiki.id),
...wiki,
});
}
}
return Array.from(byId.values());
}
function mergeStringArrayRecords(base: Record<string, string[]>, incoming: Record<string, string[]>): Record<string, string[]> {
const next: Record<string, string[]> = { ...base };
for (const [key, values] of Object.entries(incoming || {})) {
next[key] = uniqueStrings([...(next[key] || []), ...(values || [])]);
}
return next;
}
function mergeReplays(base: BattleReplay[], incoming: BattleReplay[]): BattleReplay[] {
const byId = new Map<string, BattleReplay>();
for (const replay of base || []) {
if (replay?.id) byId.set(String(replay.id), replay);
}
for (const replay of incoming || []) {
if (replay?.id) byId.set(String(replay.id), replay);
}
return Array.from(byId.values());
}
function getGeometryIdsWithReplays(fc: FeatureCollection): string[] {
return uniqueStrings((fc.features || [])
.filter((feature) => Array.isArray(feature.properties.replay_ids) && feature.properties.replay_ids.length > 0)
.map((feature) => String(feature.properties.id || "")));
}
function uniqueStrings(values: Array<string | null | undefined>): string[] {
return Array.from(new Set(
values
.map((value) => String(value || "").trim())
.filter((value) => value.length > 0)
));
}
@@ -0,0 +1,911 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Entity } from "@/uhm/api/entities";
import { fetchWikisByEntityIdsWithPreviews } from "@/uhm/api/relations";
import {
fetchWikiBySlug,
getContentByVersionWikiId,
type Wiki,
} from "@/uhm/api/wikis";
import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup";
import type { PreviewRelationIndex } from "@/uhm/lib/preview/types";
import { isTimelineYearWithinEntityTimeRange } from "@/uhm/lib/utils/entityTime";
import type { Feature, FeatureCollection } from "@/uhm/types/geo";
import type { BattleReplay } from "@/uhm/types/projects";
type CachedWiki = Wiki & { __fetched?: boolean };
type HoverWikiPreview = {
rows: Array<{
wiki: Wiki;
quote: string;
}>;
isLoaded: boolean;
};
export type LinkEntityPopupState = {
slug: string;
entities: Entity[];
top: number;
left: number;
};
export function usePublicPreviewInteraction(options: {
data: FeatureCollection;
relations: PreviewRelationIndex;
setRelations: React.Dispatch<React.SetStateAction<PreviewRelationIndex>>;
selectedFeatureIds: (string | number)[];
setSelectedFeatureIds: React.Dispatch<React.SetStateAction<(string | number)[]>>;
timelineYear?: number | null;
replayActiveWikiId?: string | null;
replayMode?: "idle" | "playing" | "paused";
onWikiLinkNavigate?: (wiki: Wiki) => void | Promise<void>;
}) {
const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, timelineYear, replayActiveWikiId, replayMode, onWikiLinkNavigate } = options;
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
const [isManualSidebarOpen, setIsManualSidebarOpen] = useState(false);
useEffect(() => {
setIsManualSidebarOpen(false);
}, [replayMode]);
const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({});
const [visibleWiki, setVisibleWiki] = useState<CachedWiki | null>(null);
const [hoverWikiPreviewByEntityId, setHoverWikiPreviewByEntityId] = useState<Record<string, HoverWikiPreview>>({});
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
const hoverWikiPreviewRequestsRef = useRef<Set<string>>(new Set());
const wikiLinkRequestSeqRef = useRef(0);
const wikiLinkInFlightSlugRef = useRef<string | null>(null);
const fullWikiFetchAttemptedSlugRef = useRef<Set<string>>(new Set());
const suppressSelectedFeatureAutoSelectRef = useRef(false);
useEffect(() => {
if (replayMode !== "idle" && replayActiveWikiId) {
const activeWikiEntityIds = relations.wikiEntityIdsById[String(replayActiveWikiId)] || [];
const entityId = activeWikiEntityIds[0] || null;
const wikiSlug = relations.wikiById[String(replayActiveWikiId)]?.slug || null;
if (entityId) {
setActiveEntityId(entityId);
}
if (wikiSlug) {
setActiveWikiSlug(wikiSlug);
}
}
}, [replayMode, replayActiveWikiId, relations.wikiEntityIdsById, relations.wikiById]);
useEffect(() => {
if (!selectedFeatureIds.length) return;
const stillExistIds = selectedFeatureIds.filter((id) =>
data.features.some((feature) => String(feature.properties.id) === String(id))
);
if (stillExistIds.length !== selectedFeatureIds.length) {
setSelectedFeatureIds(stillExistIds);
}
}, [data.features, selectedFeatureIds, setSelectedFeatureIds]);
const activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null;
const activeWiki = useMemo(() => {
if (visibleWiki) return visibleWiki;
if (!activeWikiSlug) return null;
const cachedWiki = findWikiBySlug(wikiCache, activeWikiSlug) || null;
const relationWiki = findWikiBySlug(relations.wikiBySlug, activeWikiSlug) || null;
if (!cachedWiki) return relationWiki;
if (hasWikiContent(cachedWiki) || cachedWiki.id === "__not_found__") return cachedWiki;
if (relationWiki && hasWikiContent(relationWiki)) return relationWiki;
return cachedWiki;
}, [activeWikiSlug, relations.wikiBySlug, visibleWiki, wikiCache]);
const selectEntity = useCallback(async (
entityId: string,
selectOptions?: {
sourceFeatureId?: string | number | null;
preferredWikiSlug?: string | null;
selectGeometry?: boolean;
}
) => {
let entity = relations.entitiesById[entityId] || null;
let linkedWikis = relations.entityWikisById[entityId] || [];
if (!entity) {
try {
const { fetchEntityById } = await import("@/uhm/api/entities");
entity = await fetchEntityById(entityId);
const { fetchWikisByEntityIdsWithPreviews } = await import("@/uhm/api/relations");
const wikisRes = await fetchWikisByEntityIdsWithPreviews([entityId]);
linkedWikis = wikisRes[entityId] || [];
setRelations((prev) => {
const wikiById = { ...prev.wikiById };
const wikiBySlug = { ...prev.wikiBySlug };
const wikiEntityIdsById = { ...prev.wikiEntityIdsById };
const wikiEntityIdsBySlug = { ...prev.wikiEntityIdsBySlug };
for (const w of linkedWikis) {
wikiById[w.id] = w;
if (w.slug) {
wikiBySlug[w.slug] = w;
if (!wikiEntityIdsBySlug[w.slug]) wikiEntityIdsBySlug[w.slug] = [];
if (!wikiEntityIdsBySlug[w.slug].includes(entityId)) {
wikiEntityIdsBySlug[w.slug].push(entityId);
}
}
if (!wikiEntityIdsById[w.id]) wikiEntityIdsById[w.id] = [];
if (!wikiEntityIdsById[w.id].includes(entityId)) {
wikiEntityIdsById[w.id].push(entityId);
}
}
return {
...prev,
entitiesById: {
...prev.entitiesById,
[entityId]: entity,
},
entityWikisById: {
...prev.entityWikisById,
[entityId]: linkedWikis,
},
wikiById,
wikiBySlug,
wikiEntityIdsById,
wikiEntityIdsBySlug,
};
});
} catch (err) {
console.error("Failed to lazy load entity/wikis:", err);
return;
}
}
const preferredWikiSlug = String(selectOptions?.preferredWikiSlug || "").trim();
if (!linkedWikis.length || (preferredWikiSlug && !linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug))) {
try {
const fetchedWikis = await fetchRelationWikisForEntity(entityId);
if (fetchedWikis.length) {
linkedWikis = fetchedWikis;
setRelations((prev) => mergeEntityWikisIntoRelations(prev, entityId, fetchedWikis));
setWikiCache((prev) => ({
...wikisBySlug(fetchedWikis),
...prev,
}));
}
} catch (err) {
console.error("Failed to load entity wikis before selecting:", err);
}
}
const nextWikiSlug =
(preferredWikiSlug && linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug)
? preferredWikiSlug
: "") ||
firstWikiSlug(linkedWikis);
const cachedFullWiki = nextWikiSlug ? findWikiWithContentBySlug(wikiCache, nextWikiSlug) || null : null;
setActiveEntityId(entityId);
setActiveWikiSlug(nextWikiSlug);
setVisibleWiki(cachedFullWiki);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(true);
if (selectOptions?.selectGeometry && selectOptions?.sourceFeatureId != null) {
setSelectedFeatureIds([selectOptions.sourceFeatureId]);
}
}, [relations.entitiesById, relations.entityWikisById, setRelations, setSelectedFeatureIds, wikiCache]);
const selectWiki = useCallback(async (
wiki: Wiki
) => {
const entityIds = relations.wikiEntityIdsById[wiki.id] || [];
if (entityIds.length > 0) {
await selectEntity(entityIds[0], {
preferredWikiSlug: wiki.slug,
});
return;
}
if (wiki.slug) {
const slug = wiki.slug;
const cachedFullWiki = findWikiWithContentBySlug(wikiCache, slug) || null;
setWikiCache((prev) => ({
...prev,
[slug]: cachedFullWiki || {
...wiki,
__fetched: false,
},
}));
setActiveWikiSlug(slug);
setVisibleWiki(cachedFullWiki);
}
setActiveEntityId(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(true);
}, [relations.wikiEntityIdsById, selectEntity, wikiCache]);
useEffect(() => {
if (!selectedFeatureIds.length) {
suppressSelectedFeatureAutoSelectRef.current = false;
return;
}
if (suppressSelectedFeatureAutoSelectRef.current) return;
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
if (linkedEntityIds.length !== 1) return;
const onlyEntityId = linkedEntityIds[0];
if (activeEntityId === onlyEntityId) return;
const linkedWikis = relations.entityWikisById[onlyEntityId] || [];
if (linkedWikis.length !== 1) return;
selectEntity(onlyEntityId, {
sourceFeatureId: selectedFeatureIds[0],
preferredWikiSlug: linkedWikis[0]?.slug,
selectGeometry: false,
});
}, [activeEntityId, relations.entityWikisById, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
const loadHoverWikiPreviewForEntity = useCallback(async (entityId: string) => {
try {
const relationWikis = relations.entityWikisById[entityId] || [];
const wikis = relationWikis.length ? relationWikis : await fetchRelationWikisForEntity(entityId);
if (!relationWikis.length && wikis.length) {
setRelations((prev) => mergeEntityWikisIntoRelations(prev, entityId, wikis));
setWikiCache((prev) => ({
...wikisBySlug(wikis),
...prev,
}));
}
const rows = wikis.map((wiki) => ({
wiki,
quote: cleanPreviewQuoteText(wiki.preview_quote),
}));
setHoverWikiPreviewByEntityId((prev) => ({
...prev,
[entityId]: {
rows,
isLoaded: true,
},
}));
} catch (err) {
console.error("Load hover wiki preview failed", err);
hoverWikiPreviewRequestsRef.current.delete(entityId);
setHoverWikiPreviewByEntityId((prev) => ({
...prev,
[entityId]: { rows: [], isLoaded: true },
}));
}
}, [relations.entityWikisById, setRelations]);
const getHoverPopupContent = useCallback((feature: Feature): MapHoverPopupContent | null => {
const featureId = feature.properties.id;
const entityIds = relations.geometryEntityIds[String(featureId)] || [];
const entities = entityIds
.map((entityId) => relations.entitiesById[entityId] || null)
.filter((entity): entity is Entity => Boolean(entity));
if (!entities.length) return null;
type GroupedHoverRow = MapHoverPopupContent["rows"][number] & { isTimelineMatch: boolean };
const groupedRows: GroupedHoverRow[] = entities.flatMap((entity): GroupedHoverRow[] => {
const isTimelineMatch = isTimelineYearWithinEntityTimeRange(timelineYear, entity.time_start, entity.time_end);
const preview = hoverWikiPreviewByEntityId[entity.id] ||
buildPresetHoverPreview(relations.entityWikisById[entity.id] || []);
if (!preview && !hoverWikiPreviewRequestsRef.current.has(entity.id)) {
hoverWikiPreviewRequestsRef.current.add(entity.id);
void loadHoverWikiPreviewForEntity(entity.id);
}
const baseClick = (preferredWikiSlug: string | null = null) => {
selectEntity(entity.id, {
sourceFeatureId: featureId,
preferredWikiSlug,
selectGeometry: true,
});
};
const entityHeaderRow = {
title: entity.name,
description: entity.description,
isGroupHeader: true,
isTimelineMatch,
};
if (preview?.rows.length) {
return [
entityHeaderRow,
...preview.rows.map((row) => ({
title: getWikiHoverTitle(row.wiki, entity.name),
isTimelineMatch,
quote: row.quote,
onClick: () => baseClick(String(row.wiki.slug || "").trim() || null),
})),
];
}
if (preview?.isLoaded) {
return [entityHeaderRow, {
title: "(chưa có wiki)",
titleTone: "danger",
isTimelineMatch,
quoteTone: "danger",
}];
}
return [entityHeaderRow, {
title: "Đang tải wiki...",
isTimelineMatch,
quote: "Đang tải trích dẫn wiki...",
onClick: () => baseClick(null),
}];
});
const timelineMatchedRows = groupedRows.filter((row) => row.isTimelineMatch);
const otherRows = groupedRows.filter((row) => !row.isTimelineMatch);
const stripGroupFlag = ({ isTimelineMatch: _isTimelineMatch, ...row }: GroupedHoverRow) => row;
return {
key: entities
.map((entity) => {
const preview = hoverWikiPreviewByEntityId[entity.id] ||
buildPresetHoverPreview(relations.entityWikisById[entity.id] || []);
return `${entity.id}:${preview?.isLoaded ? "loaded" : "loading"}:${preview?.rows.map((row) => row.quote).join("/") || ""}`;
})
.join("|") + `:${timelineYear ?? "none"}`,
rows: [
...timelineMatchedRows.map(stripGroupFlag),
...otherRows.map((row, index) => ({
...stripGroupFlag(row),
separatorBefore: index === 0 && timelineMatchedRows.length > 0,
})),
],
};
}, [
hoverWikiPreviewByEntityId,
loadHoverWikiPreviewForEntity,
relations.entitiesById,
relations.entityWikisById,
relations.geometryEntityIds,
selectEntity,
timelineYear,
]);
useEffect(() => {
if (!linkEntityPopup) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setLinkEntityPopup(null);
};
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && linkEntityPopupRef.current?.contains(target)) return;
setLinkEntityPopup(null);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("pointerdown", handlePointerDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("pointerdown", handlePointerDown);
};
}, [linkEntityPopup]);
useEffect(() => {
if (!activeEntityId || activeWikiSlug) return;
const existingWikis = relations.entityWikisById[activeEntityId] || [];
const existingSlug = firstWikiSlug(existingWikis);
if (existingSlug) {
setActiveWikiSlug(existingSlug);
return;
}
let disposed = false;
(async () => {
setIsActiveWikiLoading(true);
setActiveWikiError(null);
try {
const wikis = await fetchRelationWikisForEntity(activeEntityId);
if (disposed) return;
if (wikis.length) {
setRelations((prev) => mergeEntityWikisIntoRelations(prev, activeEntityId, wikis));
setWikiCache((prev) => ({
...wikisBySlug(wikis),
...prev,
}));
const nextSlug = firstWikiSlug(wikis);
if (nextSlug) {
setActiveWikiSlug(nextSlug);
} else {
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
}
} else {
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
}
} catch (err) {
if (!disposed) {
console.error("Load entity wikis failed", err);
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki cho entity đã chọn.");
}
} finally {
if (!disposed) setIsActiveWikiLoading(false);
}
})();
return () => {
disposed = true;
};
}, [activeEntityId, activeWikiSlug, relations.entityWikisById, setRelations]);
const cachedWiki = activeWikiSlug ? findWikiBySlug(wikiCache, activeWikiSlug) : undefined;
const fetchFullWikiBySlug = useCallback(async (slug: string): Promise<Wiki | null> => {
const row = await fetchWikiBySlug(slug);
if (!row) return null;
let versionContent = row.content;
try {
if (row.content_sample?.[0]?.id) {
const res = await getContentByVersionWikiId(row.content_sample[0].id);
const content = extractWikiContentFromResponse(res);
if (content) versionContent = content;
}
} catch (err) {
console.error("Failed to fetch version content:", err);
}
return { ...row, content: versionContent };
}, []);
const focusWikiLinkAfterPaint = useCallback((wiki: Wiki) => {
if (!onWikiLinkNavigate) return;
const run = () => {
void Promise.resolve(onWikiLinkNavigate(wiki)).catch((err) => {
console.error("Failed to focus map for wiki link:", err);
});
};
if (typeof window === "undefined") {
run();
return;
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(run);
});
}, [onWikiLinkNavigate]);
const publishWikiToPanel = useCallback((wiki: Wiki, requestedSlug?: string | null): CachedWiki => {
const canonicalSlug = String(wiki.slug || "").trim() || String(requestedSlug || "").trim();
const fullWiki: CachedWiki = {
...wiki,
slug: canonicalSlug,
__fetched: true,
};
if (requestedSlug) fullWikiFetchAttemptedSlugRef.current.add(requestedSlug);
if (canonicalSlug) fullWikiFetchAttemptedSlugRef.current.add(canonicalSlug);
setWikiCache((prev) => cacheWikiBySlug(prev, fullWiki, requestedSlug));
setActiveWikiSlug(canonicalSlug);
setVisibleWiki(fullWiki);
setActiveWikiError(hasWikiContent(fullWiki) ? null : "Wiki này chưa có nội dung.");
setIsActiveWikiLoading(false);
return fullWiki;
}, []);
const prepareManualWikiNavigation = useCallback(() => {
suppressSelectedFeatureAutoSelectRef.current = true;
setSelectedFeatureIds([]);
setActiveEntityId(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(true);
}, [setSelectedFeatureIds]);
useEffect(() => {
if (!activeWikiSlug) {
setIsActiveWikiLoading(false);
setActiveWikiError(null);
setVisibleWiki(null);
return;
}
if (wikiLinkInFlightSlugRef.current === activeWikiSlug) {
return;
}
if (cachedWiki?.id === "__not_found__") {
setIsActiveWikiLoading(false);
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
return;
}
if (cachedWiki && cachedWiki.__fetched && hasWikiContent(cachedWiki)) {
setVisibleWiki(cachedWiki);
setIsActiveWikiLoading(false);
setActiveWikiError(null);
return;
}
if (cachedWiki?.__fetched && fullWikiFetchAttemptedSlugRef.current.has(activeWikiSlug)) {
setIsActiveWikiLoading(false);
setActiveWikiError(hasWikiContent(cachedWiki) ? null : "Wiki này chưa có nội dung.");
return;
}
let disposed = false;
(async () => {
setIsActiveWikiLoading(true);
setActiveWikiError(null);
try {
const row = await fetchFullWikiBySlug(activeWikiSlug);
if (disposed) return;
if (row) {
publishWikiToPanel(row, activeWikiSlug);
} else {
fullWikiFetchAttemptedSlugRef.current.add(activeWikiSlug);
setWikiCache((prev) => ({
...prev,
[activeWikiSlug]: { id: "__not_found__", project_id: "" },
}));
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
}
} catch (err) {
if (disposed) return;
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
} finally {
if (!disposed) setIsActiveWikiLoading(false);
}
})();
return () => {
disposed = true;
};
}, [activeWikiSlug, cachedWiki, fetchFullWikiBySlug, publishWikiToPanel]);
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
const nextSlug = String(slug || "").trim();
if (!nextSlug) return;
const requestSeq = ++wikiLinkRequestSeqRef.current;
wikiLinkInFlightSlugRef.current = nextSlug;
prepareManualWikiNavigation();
const cachedWikiForSlug =
findWikiWithContentBySlug(wikiCache, nextSlug) ||
findWikiWithContentBySlug(relations.wikiBySlug, nextSlug) ||
null;
if (cachedWikiForSlug && cachedWikiForSlug.id !== "__not_found__" && hasWikiContent(cachedWikiForSlug)) {
wikiLinkInFlightSlugRef.current = null;
const fullWiki = publishWikiToPanel(cachedWikiForSlug, nextSlug);
focusWikiLinkAfterPaint(fullWiki);
return;
}
setIsActiveWikiLoading(true);
let row: Wiki | null = null;
try {
row = await fetchFullWikiBySlug(nextSlug);
} catch (err) {
console.error("Load wiki by slug failed", err);
if (requestSeq !== wikiLinkRequestSeqRef.current) return;
if (wikiLinkInFlightSlugRef.current === nextSlug) wikiLinkInFlightSlugRef.current = null;
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
setIsActiveWikiLoading(false);
return;
}
if (requestSeq !== wikiLinkRequestSeqRef.current) return;
if (wikiLinkInFlightSlugRef.current === nextSlug) wikiLinkInFlightSlugRef.current = null;
if (!row) {
setActiveWikiError("Không tìm thấy wiki.");
setIsActiveWikiLoading(false);
return;
}
const fullWiki = publishWikiToPanel(row, nextSlug);
focusWikiLinkAfterPaint(fullWiki);
}, [
fetchFullWikiBySlug,
focusWikiLinkAfterPaint,
prepareManualWikiNavigation,
publishWikiToPanel,
relations.wikiBySlug,
wikiCache,
]);
const closeWikiSidebar = useCallback(() => {
setActiveEntityId(null);
setActiveWikiSlug(null);
setVisibleWiki(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setSelectedFeatureIds([]);
setIsManualSidebarOpen(false);
}, [setSelectedFeatureIds]);
const closeWikiSidebarPreserveSelection = useCallback(() => {
setActiveEntityId(null);
setActiveWikiSlug(null);
setVisibleWiki(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setIsManualSidebarOpen(false);
}, []);
return {
activeEntity,
activeWiki,
isActiveWikiLoading,
activeWikiError,
linkEntityPopup,
linkEntityPopupRef,
getHoverPopupContent,
selectEntity,
selectWiki,
handleWikiLinkRequest,
closeWikiSidebar,
closeWikiSidebarPreserveSelection,
setLinkEntityPopup,
isManualSidebarOpen,
setIsManualSidebarOpen,
};
}
async function fetchRelationWikisForEntity(entityId: string): Promise<Wiki[]> {
const rows = await fetchWikisByEntityIdsWithPreviews([entityId]);
return rows[entityId] || [];
}
function extractWikiContentFromResponse(response: unknown): string {
if (typeof response === "string") return response;
if (!response || typeof response !== "object") return "";
const source = response as Record<string, unknown>;
if (typeof source.content === "string") return source.content;
const data = source.data;
if (data && typeof data === "object" && typeof (data as Record<string, unknown>).content === "string") {
return (data as Record<string, unknown>).content as string;
}
return "";
}
function hasWikiContent(wiki: Wiki | null | undefined): boolean {
return typeof wiki?.content === "string" && wiki.content.trim().length > 0;
}
function cacheWikiBySlug(
prev: Record<string, CachedWiki>,
wiki: CachedWiki,
requestedSlug?: string | null
): Record<string, CachedWiki> {
const next = { ...prev };
for (const key of wikiSlugCacheKeys(requestedSlug, wiki.slug)) {
next[key] = wiki;
}
return next;
}
function findWikiWithContentBySlug<T extends Wiki>(
source: Record<string, T>,
slug: string | null | undefined
): T | undefined {
const direct = findWikiBySlug(source, slug);
if (direct && hasWikiContent(direct)) return direct;
const targetKey = normalizeWikiSlugForCompare(slug);
if (!targetKey) return undefined;
for (const [key, wiki] of Object.entries(source)) {
if (!hasWikiContent(wiki)) continue;
const keyMatches = normalizeWikiSlugForCompare(key) === targetKey;
const slugMatches = normalizeWikiSlugForCompare(wiki.slug) === targetKey;
if (keyMatches || slugMatches) return wiki;
}
return undefined;
}
function findWikiBySlug<T extends Wiki>(
source: Record<string, T>,
slug: string | null | undefined
): T | undefined {
const keys = wikiSlugCacheKeys(slug);
for (const key of keys) {
const direct = source[key];
if (direct) return direct;
}
const targetKey = normalizeWikiSlugForCompare(slug);
if (!targetKey) return undefined;
for (const [key, wiki] of Object.entries(source)) {
const keyMatches = normalizeWikiSlugForCompare(key) === targetKey;
const slugMatches = normalizeWikiSlugForCompare(wiki.slug) === targetKey;
if (keyMatches || slugMatches) return wiki;
}
return undefined;
}
function wikiSlugCacheKeys(...values: Array<string | null | undefined>): string[] {
const keys = new Set<string>();
for (const value of values) {
const raw = String(value || "").trim();
if (!raw) continue;
keys.add(raw);
let decoded = raw;
try {
decoded = decodeURIComponent(raw);
} catch {
decoded = raw;
}
decoded = decoded.replace(/^\/+/, "").replace(/^wiki\//i, "").trim();
if (!decoded) continue;
keys.add(decoded);
keys.add(decoded.replace(/_/g, " "));
keys.add(decoded.replace(/\s+/g, "_"));
keys.add(normalizeWikiSlugForCompare(decoded));
}
return Array.from(keys).filter((key) => key.length > 0);
}
function normalizeWikiSlugForCompare(value: string | null | undefined): string {
let raw = String(value || "").trim();
if (!raw) return "";
try {
raw = decodeURIComponent(raw);
} catch {
// Keep the original value if it is not valid percent-encoded text.
}
return raw
.replace(/^\/+/, "")
.replace(/^wiki\//i, "")
.replace(/_/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLocaleLowerCase("vi-VN");
}
function cleanPreviewQuoteText(content: string | null | undefined): string {
if (!content) return "";
const blockquoteMatch = content.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
let rawText = blockquoteMatch ? (blockquoteMatch[1]?.trim() || "") : content.trim();
rawText = rawText.replace(/<\/?blockquote[^>]*>/gi, "");
return rawText
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/\u00a0/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function buildPresetHoverPreview(wikis: Wiki[]): HoverWikiPreview | undefined {
const rows = (wikis || [])
.map((wiki) => ({
wiki,
quote: cleanPreviewQuoteText(wiki.preview_quote),
}));
return rows.length ? { rows, isLoaded: true } : undefined;
}
function extractWikiBlockquoteText(content: string | null | undefined): string {
if (!content) return "";
const blockquoteMatch = content.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
const rawText = blockquoteMatch?.[1]?.trim() || "";
if (!rawText) return "";
return rawText
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/\u00a0/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function mergeEntityWikisIntoRelations(
prev: PreviewRelationIndex,
entityId: string,
wikis: Wiki[]
): PreviewRelationIndex {
const wikiById = { ...prev.wikiById };
const wikiBySlug = { ...prev.wikiBySlug };
const entityWikis = [...(prev.entityWikisById[entityId] || [])];
const wikiEntityIdsById = cloneStringArrayRecord(prev.wikiEntityIdsById);
const wikiEntityIdsBySlug = cloneStringArrayRecord(prev.wikiEntityIdsBySlug);
for (const wiki of wikis) {
if (!wiki?.id) continue;
wikiById[wiki.id] = wiki;
if (!entityWikis.some((item) => item.id === wiki.id)) entityWikis.push(wiki);
appendUnique(wikiEntityIdsById, wiki.id, entityId);
const slug = String(wiki.slug || "").trim();
if (slug) {
wikiBySlug[slug] = wiki;
appendUnique(wikiEntityIdsBySlug, slug, entityId);
}
}
return {
...prev,
entityWikisById: {
...prev.entityWikisById,
[entityId]: entityWikis,
},
wikiEntityIdsById,
wikiEntityIdsBySlug,
wikiById,
wikiBySlug,
};
}
function wikisBySlug(wikis: Wiki[]): Record<string, Wiki> {
const result: Record<string, Wiki> = {};
for (const wiki of wikis) {
const slug = String(wiki?.slug || "").trim();
if (slug) result[slug] = wiki;
}
return result;
}
function firstWikiSlug(wikis: Wiki[]): string | null {
return wikis.map((wiki) => String(wiki.slug || "").trim()).find((slug) => slug.length > 0) || null;
}
function getWikiHoverTitle(wiki: Wiki | null | undefined, fallbackTitle: string): string {
return String(wiki?.title || "").trim() || fallbackTitle;
}
function cloneStringArrayRecord(source: Record<string, string[]>): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const [key, value] of Object.entries(source)) {
result[key] = [...value];
}
return result;
}
function appendUnique(target: Record<string, string[]>, key: string, value: string) {
if (!target[key]) {
target[key] = [value];
return;
}
if (!target[key].includes(value)) target[key].push(value);
}
function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) {
const margin = 12;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
const preferredLeft = rect.right + margin;
const maxLeft = Math.max(margin, viewportWidth - width - margin);
const left = Math.min(preferredLeft, maxLeft);
const preferredTop = rect.top;
const maxTop = Math.max(margin, viewportHeight - height - margin);
const top = Math.max(margin, Math.min(preferredTop, maxTop));
return { top, left };
}
+49
View File
@@ -0,0 +1,49 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
size?: "sm" | "md";
variant?: "primary" | "outline";
startIcon?: ReactNode;
endIcon?: ReactNode;
};
export default function Button({
children,
size = "md",
variant = "primary",
startIcon,
endIcon,
className = "",
disabled = false,
type = "button",
...rest
}: ButtonProps) {
const sizeClasses = {
sm: "px-4 py-3 text-sm",
md: "px-5 py-3.5 text-sm",
};
const variantClasses = {
primary:
"bg-brand-500 text-white shadow-theme-xs hover:bg-brand-600 disabled:bg-brand-300",
outline:
"bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700 dark:hover:bg-white/[0.03] dark:hover:text-gray-300",
};
return (
<button
className={`inline-flex items-center justify-center font-medium gap-2 rounded-lg transition ${className} ${
sizeClasses[size]
} ${variantClasses[variant]} ${
disabled ? "cursor-not-allowed opacity-50" : ""
}`}
disabled={disabled}
type={type}
{...rest}
>
{startIcon && <span className="flex items-center">{startIcon}</span>}
{children}
{endIcon && <span className="flex items-center">{endIcon}</span>}
</button>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
type ChatbotWidgetProps = {
projectId?: string;
hideFloatingButton?: boolean;
};
export default function ChatbotWidget({
projectId = "",
hideFloatingButton = false,
}: ChatbotWidgetProps) {
return null;
}
+20
View File
@@ -0,0 +1,20 @@
import type { LabelHTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
type LabelProps = LabelHTMLAttributes<HTMLLabelElement> & {
children: ReactNode;
};
export default function Label({ children, className, ...props }: LabelProps) {
return (
<label
className={twMerge(
"mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400",
className
)}
{...props}
>
{children}
</label>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type ModalProps = {
isOpen: boolean;
onClose: () => void;
className?: string;
children: ReactNode;
showCloseButton?: boolean;
isFullscreen?: boolean;
};
export function Modal({
isOpen,
onClose,
children,
className = "",
showCloseButton = true,
isFullscreen = false,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
if (!isOpen) return null;
const contentClasses = isFullscreen
? "w-full h-full"
: "relative w-full rounded-xl bg-white dark:bg-gray-900";
return (
<div className="fixed inset-0 z-99999 flex items-center justify-center overflow-y-auto modal">
{!isFullscreen && (
<button
type="button"
aria-label="Close modal backdrop"
className="fixed inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
onClick={onClose}
/>
)}
<div
ref={modalRef}
className={`${contentClasses} ${className}`}
onClick={(event) => event.stopPropagation()}
>
{showCloseButton && (
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
)}
<div>{children}</div>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import type { PointerEvent as ReactPointerEvent } from "react";
type ResizeHandleProps = {
onDrag: (deltaX: number) => void;
title: string;
};
export function ResizeHandle({ onDrag, title }: ResizeHandleProps) {
// Theo dõi pointer toàn window để resize vẫn mượt khi cursor đi ra khỏi handle.
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
// Tạo đường ghost ảo chỉ vị trí kéo thay vì kích hoạt re-render liên tục
const ghost = document.createElement("div");
ghost.style.position = "fixed";
ghost.style.top = "0";
ghost.style.bottom = "0";
ghost.style.width = "4px";
ghost.style.backgroundColor = "#38bdf8";
ghost.style.boxShadow = "0 0 12px rgba(56, 189, 248, 0.8)";
ghost.style.zIndex = "99999";
ghost.style.cursor = "col-resize";
ghost.style.pointerEvents = "none";
ghost.style.left = `${startX}px`;
document.body.appendChild(ghost);
const onMove = (e: PointerEvent) => {
ghost.style.left = `${e.clientX}px`;
};
const onUp = (e: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
if (ghost.parentNode) {
ghost.parentNode.removeChild(ghost);
}
const deltaX = e.clientX - startX;
if (deltaX !== 0) {
onDrag(deltaX);
}
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
return (
<div
role="separator"
aria-orientation="vertical"
title={title}
onPointerDown={handlePointerDown}
style={{
width: 6,
cursor: "col-resize",
background: "rgba(148, 163, 184, 0.08)",
borderLeft: "1px solid rgba(148, 163, 184, 0.18)",
borderRight: "1px solid rgba(148, 163, 184, 0.18)",
flex: "0 0 auto",
}}
/>
);
}
+856
View File
@@ -0,0 +1,856 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/utils/timeline";
import styles from "@/styles/TimelineBar.module.css";
type Props = {
year: number;
onYearChange: (year: number) => void;
timeRange?: number;
onTimeRangeChange?: (range: number) => void;
isLoading: boolean;
disabled: boolean;
statusText?: string | null;
filterEnabled?: boolean;
onFilterEnabledChange?: (enabled: boolean) => void;
style?: React.CSSProperties;
onPlayReplay?: () => void;
};
export default function TimelineBar({
year,
onYearChange,
timeRange,
onTimeRangeChange,
isLoading,
disabled,
statusText,
filterEnabled,
onFilterEnabledChange,
style,
onPlayReplay,
}: Props) {
const lower = FIXED_TIMELINE_START_YEAR;
const upper = FIXED_TIMELINE_END_YEAR;
const effectiveDisabled = disabled;
const safeYear = clampYearValue(year, lower, upper);
const [localYear, setLocalYear] = useState<number | null>(null);
const displayYear = localYear ?? safeYear;
const localYearRef = useRef(displayYear);
const onYearChangeRef = useRef(onYearChange);
useEffect(() => {
localYearRef.current = displayYear;
}, [displayYear]);
useEffect(() => {
onYearChangeRef.current = onYearChange;
}, [onYearChange]);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastTriggeredYearRef = useRef<number | null>(null);
const lastTriggerTimeRef = useRef<number>(0);
const commitYearChange = useCallback((nextVal: number) => {
const clamped = clampYearValue(Math.trunc(nextVal), lower, upper);
if (!Number.isFinite(clamped)) return;
lastTriggeredYearRef.current = clamped;
lastTriggerTimeRef.current = Date.now();
onYearChangeRef.current(clamped);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
}, [lower, upper]);
const handleLocalYearChange = useCallback((nextVal: number) => {
if (!Number.isFinite(nextVal)) {
return;
}
const clamped = clampYearValue(Math.trunc(nextVal), lower, upper);
localYearRef.current = clamped;
setLocalYear(clamped);
const now = Date.now();
if (now - lastTriggerTimeRef.current >= 100) {
commitYearChange(clamped);
} else {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
commitYearChange(clamped);
}, 100);
}
}, [lower, upper, commitYearChange]);
const finishLocalYearChange = useCallback(() => {
commitYearChange(localYearRef.current);
setLocalYear(null);
}, [commitYearChange]);
useEffect(() => {
if (localYear !== null) return;
localYearRef.current = safeYear;
lastTriggeredYearRef.current = safeYear;
}, [localYear, safeYear]);
const startChangingYear = (direction: number) => {
if (effectiveDisabled) return;
const nextVal = localYearRef.current + direction;
handleLocalYearChange(nextVal);
timeoutRef.current = setTimeout(() => {
intervalRef.current = setInterval(() => {
const currentVal = localYearRef.current;
const targetVal = currentVal + direction;
if (targetVal < lower || targetVal > upper) {
stopChangingYear();
return;
}
handleLocalYearChange(targetVal);
}, 80);
}, 400);
};
const stopChangingYear = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
finishLocalYearChange();
};
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, []);
const helperText = isLoading
? "Đang tải geometry theo mốc thời gian..."
: statusText || null;
const handleTimeRangeChange = (nextValue: number) => {
if (!onTimeRangeChange) return;
const safe = Number.isFinite(nextValue) ? Math.trunc(nextValue) : 0;
onTimeRangeChange(Math.max(0, Math.min(30, safe)));
};
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
useEffect(() => {
if (isMobile && typeof timeRange === "number" && timeRange !== 0 && onTimeRangeChange) {
onTimeRangeChange(0);
}
}, [isMobile, timeRange, onTimeRangeChange]);
if (isMobile) {
return (
<div
style={{
position: "absolute",
zIndex: style?.zIndex ?? 10,
display: "flex",
flexDirection: "column",
gap: 8,
pointerEvents: "none",
...style,
left: style?.left ?? 18,
right: style?.right ?? 18,
bottom: style?.bottom ?? 16,
}}
title={helperText || undefined}
>
{/* Year Controls row above */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
margin: "0 auto",
background: "transparent",
border: "none",
borderRadius: "0px",
padding: "6px 0",
pointerEvents: "auto",
boxShadow: "none",
backdropFilter: "none",
WebkitBackdropFilter: "none",
}}
>
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
<>
<button
type="button"
role="switch"
aria-checked={filterEnabled}
aria-label="Toggle timeline filter"
title={filterEnabled ? "Đang bật lọc timeline" : "Đang tắt lọc timeline (hiển thị tất cả geometry)"}
className={`${styles.toggleContainer} ${effectiveDisabled ? styles.disabled : ""}`}
onClick={() => onFilterEnabledChange(!filterEnabled)}
disabled={effectiveDisabled}
>
<span
aria-hidden="true"
className={`${styles.toggleTrack} ${filterEnabled ? styles.toggleTrackActive : ""}`}
>
<span
className={`${styles.toggleThumb} ${filterEnabled ? styles.toggleThumbActive : ""}`}
/>
</span>
</button>
<div style={{ width: 1, height: 16, backgroundColor: "rgba(255, 255, 255, 0.15)" }} />
</>
) : null}
{/* Adjust decrease button on the left of input */}
<button
type="button"
onMouseDown={() => startChangingYear(-1)}
onMouseUp={stopChangingYear}
onMouseLeave={stopChangingYear}
onTouchStart={(e) => {
e.preventDefault();
startChangingYear(-1);
}}
onTouchEnd={(e) => {
e.preventDefault();
stopChangingYear();
}}
disabled={effectiveDisabled}
className={styles.adjustBtn}
style={{ width: 32, height: 32, borderRadius: 8, fontSize: 16 }}
title="Giảm 1 năm"
aria-label="Giảm 1 năm"
>
-
</button>
{/* Year Input */}
<input
type="number"
min={lower}
max={upper}
step={1}
value={displayYear}
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
onBlur={finishLocalYearChange}
onKeyDown={(e) => {
if (e.key === "Enter") {
finishLocalYearChange();
}
}}
disabled={effectiveDisabled}
className={styles.numberInput}
style={{ width: 90, textAlign: "center", height: 32, padding: "0 6px" }}
aria-label="Timeline exact year"
/>
{/* Adjust increase button on the right of input */}
<button
type="button"
onMouseDown={() => startChangingYear(1)}
onMouseUp={stopChangingYear}
onMouseLeave={stopChangingYear}
onTouchStart={(e) => {
e.preventDefault();
startChangingYear(1);
}}
onTouchEnd={(e) => {
e.preventDefault();
stopChangingYear();
}}
disabled={effectiveDisabled}
className={styles.adjustBtn}
style={{ width: 32, height: 32, borderRadius: 8, fontSize: 16 }}
title="Tăng 1 năm"
aria-label="Tăng 1 năm"
>
+
</button>
{onPlayReplay ? (
<>
<div style={{ width: 1, height: 16, backgroundColor: "rgba(255, 255, 255, 0.15)" }} />
<button
type="button"
onClick={onPlayReplay}
style={{
width: 32,
height: 32,
borderRadius: "50%",
backgroundColor: "#2563eb",
border: "1px solid rgba(255, 255, 255, 0.2)",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
flexShrink: 0,
}}
title="Xem diễn biến lịch sử (Replay)"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</button>
</>
) : null}
</div>
{/* Ruler row below: full width */}
<div
className={`${styles.container} ${isLoading ? styles.containerLoading : ""} ${effectiveDisabled ? styles.disabled : ""}`}
style={{
position: "static", // Override absolute positioning of .container
padding: "8px 12px",
pointerEvents: "auto",
width: "100%",
borderRadius: "24px",
}}
>
<div className={styles.flexWrapper} style={{ width: "100%", display: "flex" }}>
<CanvasTimelineRuler
year={displayYear}
onYearChange={handleLocalYearChange}
onYearCommit={finishLocalYearChange}
minYear={lower}
maxYear={upper}
disabled={effectiveDisabled}
/>
</div>
</div>
</div>
);
}
return (
<div
className={`${styles.container} ${isLoading ? styles.containerLoading : ""} ${effectiveDisabled ? styles.disabled : ""}`}
style={style}
title={helperText || undefined}
>
<div className={styles.flexWrapper}>
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? (
<button
type="button"
role="switch"
aria-checked={filterEnabled}
aria-label="Toggle timeline filter"
title={filterEnabled ? "Dang bat loc timeline" : "Dang tat loc timeline (hien thi tat ca geometry)"}
className={`${styles.toggleContainer} ${effectiveDisabled ? styles.disabled : ""}`}
onClick={() => onFilterEnabledChange(!filterEnabled)}
disabled={effectiveDisabled}
>
<span
aria-hidden="true"
className={`${styles.toggleTrack} ${filterEnabled ? styles.toggleTrackActive : ""}`}
>
<span
className={`${styles.toggleThumb} ${filterEnabled ? styles.toggleThumbActive : ""}`}
/>
</span>
</button>
) : null}
<CanvasTimelineRuler
year={displayYear}
onYearChange={handleLocalYearChange}
onYearCommit={finishLocalYearChange}
minYear={lower}
maxYear={upper}
disabled={effectiveDisabled}
/>
<div className={styles.numberWrapper}>
<input
type="number"
min={lower}
max={upper}
step={1}
value={displayYear}
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
onBlur={finishLocalYearChange}
onKeyDown={(e) => {
if (e.key === "Enter") {
finishLocalYearChange();
}
}}
disabled={effectiveDisabled}
className={styles.numberInput}
aria-label="Timeline exact year"
/>
<div className={styles.adjustGroup}>
<button
type="button"
onMouseDown={() => startChangingYear(-1)}
onMouseUp={stopChangingYear}
onMouseLeave={stopChangingYear}
onTouchStart={(e) => {
e.preventDefault();
startChangingYear(-1);
}}
onTouchEnd={(e) => {
e.preventDefault();
stopChangingYear();
}}
disabled={effectiveDisabled}
className={styles.adjustBtn}
title="Giảm 1 năm"
aria-label="Giảm 1 năm"
>
-
</button>
<button
type="button"
onMouseDown={() => startChangingYear(1)}
onMouseUp={stopChangingYear}
onMouseLeave={stopChangingYear}
onTouchStart={(e) => {
e.preventDefault();
startChangingYear(1);
}}
onTouchEnd={(e) => {
e.preventDefault();
stopChangingYear();
}}
disabled={effectiveDisabled}
className={styles.adjustBtn}
title="Tăng 1 năm"
aria-label="Tăng 1 năm"
>
+
</button>
</div>
</div>
{typeof timeRange === "number" && onTimeRangeChange ? (
<label
title="time_range (0-30)"
className={`${styles.rangeLabel} ${effectiveDisabled ? styles.disabled : ""}`}
>
<span>Range</span>
<input
type="number"
min={0}
max={30}
step={1}
value={Math.max(0, Math.min(30, Math.trunc(timeRange)))}
onChange={(event) => handleTimeRangeChange(Number(event.target.value))}
disabled={effectiveDisabled}
className={styles.rangeInput}
aria-label="Timeline range"
/>
</label>
) : null}
</div>
</div>
);
}
function formatYear(year: number): string {
if (year < 0) {
return `${Math.abs(year)} TCN`;
}
return `${year}`;
}
interface CanvasRulerProps {
year: number;
onYearChange: (year: number) => void;
onYearCommit: () => void;
minYear: number;
maxYear: number;
disabled?: boolean;
}
function CanvasTimelineRuler({
year,
onYearChange,
onYearCommit,
minYear,
maxYear,
disabled = false,
}: CanvasRulerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Visible span (in years)
const [span, setSpan] = useState(400); // default show 400 years
// Dimensions
const [dimensions, setDimensions] = useState({ width: 0, height: 48 });
// Internal tracker for current display year to decouple render lag
const displayYearRef = useRef(year);
// Dragging state
const dragRef = useRef<{
isDragging: boolean;
startX: number;
startYear: number;
hasDragged: boolean;
} | null>(null);
const activePointersRef = useRef<Record<number, { clientX: number; clientY: number }>>({});
const initialPinchDistanceRef = useRef<number | null>(null);
const initialSpanRef = useRef<number | null>(null);
// Sync dimensions using ResizeObserver
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
if (!entries || !entries[0]) return;
const { width, height } = entries[0].contentRect;
setDimensions({ width, height: height || 48 });
});
observer.observe(container);
return () => observer.disconnect();
}, []);
// Draw the ruler on canvas
const drawYear = useCallback((currentYear: number) => {
const canvas = canvasRef.current;
if (!canvas || dimensions.width === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const width = dimensions.width;
const height = dimensions.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
// Center year is the selected year
const startYear = currentYear - span / 2;
const endYear = currentYear + span / 2;
const yearToX = (y: number) => {
return ((y - startYear) / span) * width;
};
// Determine tick step based on span
let majorStep = 100;
let mediumStep = 10;
let minorStep = 1;
if (span > 3000) {
majorStep = 1000;
mediumStep = 100;
minorStep = 10;
} else if (span > 1500) {
majorStep = 500;
mediumStep = 50;
minorStep = 10;
} else if (span > 600) {
majorStep = 100;
mediumStep = 20;
minorStep = 5;
} else if (span > 200) {
majorStep = 100;
mediumStep = 10;
minorStep = 1;
} else if (span > 60) {
majorStep = 50;
mediumStep = 10;
minorStep = 1;
} else {
majorStep = 10;
mediumStep = 5;
minorStep = 1;
}
// Ticks drawing bounds
const firstMajor = Math.floor(startYear / majorStep) * majorStep;
const lastMajor = Math.ceil(endYear / majorStep) * majorStep;
const pixelsPerYear = width / span;
const showMinor = pixelsPerYear * minorStep >= 3;
const showMedium = pixelsPerYear * mediumStep >= 5;
// Draw ruler track baseline
ctx.beginPath();
ctx.moveTo(0, height - 8);
ctx.lineTo(width, height - 8);
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
ctx.lineWidth = 1;
ctx.stroke();
// 1. Draw minor & medium ticks
ctx.beginPath();
for (let y = Math.floor(startYear); y <= Math.ceil(endYear); y++) {
if (y < minYear || y > maxYear) continue;
const isMajor = y % majorStep === 0;
const isMedium = y % mediumStep === 0;
const isMinor = y % minorStep === 0;
if (isMajor) continue;
let tickHeight = 0;
if (isMedium && showMedium) {
tickHeight = 7;
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
} else if (isMinor && showMinor) {
tickHeight = 4;
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
}
if (tickHeight > 0) {
const x = yearToX(y);
ctx.moveTo(x, height - 8);
ctx.lineTo(x, height - 8 - tickHeight);
}
}
ctx.lineWidth = 1;
ctx.stroke();
// 2. Draw major ticks and labels
ctx.fillStyle = "rgba(255, 255, 255, 0.75)";
ctx.font = "600 10px system-ui, -apple-system, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "top";
for (let y = firstMajor; y <= lastMajor; y += majorStep) {
if (y < minYear || y > maxYear) continue;
const x = yearToX(y);
// Draw tick line
ctx.beginPath();
ctx.moveTo(x, height - 8);
ctx.lineTo(x, height - 20);
ctx.strokeStyle = "rgba(255, 255, 255, 0.65)";
ctx.lineWidth = 1.25;
ctx.stroke();
// Draw label
const label = formatYear(y);
ctx.fillText(label, x, height - 33);
}
// 3. Draw needle indicator in the center
const needleX = width / 2;
ctx.beginPath();
ctx.moveTo(needleX, 0);
ctx.lineTo(needleX, height - 4);
ctx.strokeStyle = "#10b981";
ctx.lineWidth = 2;
ctx.shadowColor = "rgba(16, 185, 129, 0.6)";
ctx.shadowBlur = 6;
ctx.stroke();
// Draw needle head triangle
ctx.fillStyle = "#10b981";
ctx.beginPath();
ctx.moveTo(needleX - 5, 0);
ctx.lineTo(needleX + 5, 0);
ctx.lineTo(needleX, 6);
ctx.closePath();
ctx.fill();
ctx.restore();
}, [span, dimensions, minYear, maxYear]);
// Redraw when dimensions change
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || dimensions.width === 0) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = dimensions.width * dpr;
canvas.height = dimensions.height * dpr;
drawYear(displayYearRef.current);
}, [dimensions, drawYear]);
// Redraw when span changes
useEffect(() => {
drawYear(displayYearRef.current);
}, [span, drawYear]);
// Sync externally changed year
useEffect(() => {
if (!dragRef.current || !dragRef.current.isDragging) {
displayYearRef.current = year;
drawYear(year);
}
}, [year, drawYear]);
const handleWheel = (e: React.WheelEvent) => {
if (disabled) return;
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 1.15 : 0.85;
const nextSpan = Math.max(10, Math.min(10000, span * zoomFactor));
setSpan(Math.round(nextSpan));
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
e.preventDefault();
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {}
activePointersRef.current[e.pointerId] = { clientX: e.clientX, clientY: e.clientY };
const activePointerIds = Object.keys(activePointersRef.current);
if (activePointerIds.length === 2) {
const p1 = activePointersRef.current[Number(activePointerIds[0])];
const p2 = activePointersRef.current[Number(activePointerIds[1])];
const dx = p1.clientX - p2.clientX;
const dy = p1.clientY - p2.clientY;
initialPinchDistanceRef.current = Math.sqrt(dx * dx + dy * dy);
initialSpanRef.current = span;
if (dragRef.current) {
dragRef.current.isDragging = false;
}
} else {
dragRef.current = {
isDragging: true,
startX: e.clientX,
startYear: displayYearRef.current,
hasDragged: false,
};
}
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
e.preventDefault();
activePointersRef.current[e.pointerId] = { clientX: e.clientX, clientY: e.clientY };
const activePointerIds = Object.keys(activePointersRef.current);
if (activePointerIds.length === 2 && initialPinchDistanceRef.current !== null && initialSpanRef.current !== null) {
const p1 = activePointersRef.current[Number(activePointerIds[0])];
const p2 = activePointersRef.current[Number(activePointerIds[1])];
const dx = p1.clientX - p2.clientX;
const dy = p1.clientY - p2.clientY;
const currentDist = Math.sqrt(dx * dx + dy * dy);
if (currentDist > 5) {
const zoomFactor = initialPinchDistanceRef.current / currentDist;
const nextSpan = Math.max(10, Math.min(10000, initialSpanRef.current * zoomFactor));
setSpan(Math.round(nextSpan));
}
} else if (dragRef.current && dragRef.current.isDragging) {
const dx = e.clientX - dragRef.current.startX;
if (Math.abs(dx) > 3) {
dragRef.current.hasDragged = true;
}
const yearsPerPixel = span / dimensions.width;
const deltaYears = -dx * yearsPerPixel;
const nextYear = clampYearValue(Math.round(dragRef.current.startYear + deltaYears), minYear, maxYear);
if (nextYear !== displayYearRef.current) {
displayYearRef.current = nextYear;
requestAnimationFrame(() => {
drawYear(displayYearRef.current);
});
onYearChange(nextYear);
}
}
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
e.preventDefault();
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
delete activePointersRef.current[e.pointerId];
if (Object.keys(activePointersRef.current).length < 2) {
initialPinchDistanceRef.current = null;
initialSpanRef.current = null;
}
if (!dragRef.current) return;
const dragInfo = dragRef.current;
dragRef.current = null;
if (dragInfo.isDragging && !dragInfo.hasDragged) {
const canvas = canvasRef.current;
if (canvas) {
const rect = canvas.getBoundingClientRect();
const clickedX = e.clientX - rect.left;
const centerYear = displayYearRef.current;
const startYear = centerYear - span / 2;
const clickedYear = clampYearValue(
Math.round(startYear + (clickedX / rect.width) * span),
minYear,
maxYear
);
displayYearRef.current = clickedYear;
drawYear(clickedYear);
onYearChange(clickedYear);
}
}
onYearCommit();
};
return (
<div
ref={containerRef}
style={{
flex: 1,
height: 44,
position: "relative",
background: "rgba(255, 255, 255, 0.04)",
borderRadius: 22,
border: "1px solid rgba(255, 255, 255, 0.08)",
overflow: "hidden",
cursor: disabled ? "not-allowed" : "ew-resize",
touchAction: "none",
}}
onWheel={handleWheel}
>
<canvas
ref={canvasRef}
style={{
display: "block",
width: "100%",
height: "100%",
touchAction: "none",
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
export type UnifiedSearchKind = "entity" | "wiki" | "geo";
type Props = {
kind: UnifiedSearchKind;
onKindChange: (kind: UnifiedSearchKind) => void;
query: string;
onQueryChange: (query: string) => void;
disabledGeo?: boolean;
debounceMs?: number;
onLocalQueryChange?: (query: string) => void;
};
export default function UnifiedSearchBar({
kind,
onKindChange,
query,
onQueryChange,
disabledGeo,
debounceMs = 300,
onLocalQueryChange,
}: Props) {
// Local input state to avoid propagating query changes (and triggering API) on every keystroke.
const [localQuery, setLocalQuery] = useState(query);
const debounceTimerRef = useRef<number | null>(null);
// Keep local input in sync when parent updates `query` externally (e.g. reset, preset, navigation).
useEffect(() => {
setLocalQuery(query);
}, [query]);
useEffect(() => {
onLocalQueryChange?.(localQuery);
}, [localQuery, onLocalQueryChange]);
// Debounce propagation upwards.
useEffect(() => {
if (localQuery === query) return;
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = window.setTimeout(() => {
onQueryChange(localQuery);
}, debounceMs);
return () => {
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
};
}, [localQuery, query, onQueryChange, debounceMs]);
const commitNow = () => {
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
if (localQuery !== query) onQueryChange(localQuery);
};
const selectStyle: CSSProperties = {
width: 110,
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: 6,
padding: "8px 10px",
fontSize: 12,
outline: "none",
flex: "0 0 auto",
};
const inputStyle: CSSProperties = {
width: "100%",
border: "1px solid #1f2937",
background: "#0b1220",
color: "#e5e7eb",
borderRadius: 6,
padding: "8px 10px",
fontSize: 12,
outline: "none",
minWidth: 0,
};
const helperText =
kind === "entity"
? "Search entity theo name"
: kind === "wiki"
? "Search wiki theo title"
: "Search geo theo entity name";
return (
<div
style={{
padding: 10,
background: "#0b1220",
borderRadius: 8,
border: "1px solid #1f2937",
display: "grid",
gap: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>Search</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{helperText}</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<select
value={kind}
onChange={(e) => onKindChange(e.target.value as UnifiedSearchKind)}
style={selectStyle}
aria-label="Search kind"
>
<option value="entity">Entity</option>
<option value="wiki">Wiki</option>
<option value="geo" disabled={Boolean(disabledGeo)}>
Geo
</option>
</select>
<input
value={localQuery}
onChange={(e) => setLocalQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitNow();
}}
onBlur={() => commitNow()}
placeholder={kind === "entity" ? "Nhập tên entity…" : kind === "wiki" ? "Nhập title wiki…" : "Nhập tên entity…"}
style={inputStyle}
aria-label="Search query"
/>
</div>
</div>
);
}
@@ -0,0 +1,925 @@
"use client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, memo } from "react";
import { createPortal } from "react-dom";
// Loaded dynamically inside the component to prevent render-blocking
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
type TocItem = {
id: string;
level: number;
text: string;
};
type Props = {
entity: Entity | null;
wiki: Wiki | null;
isLoading: boolean;
error?: string | null;
onClose: () => void;
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void;
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxDragWidth?: number;
compactHeader?: boolean;
sidebarHeight?: number;
onSidebarHeightChange?: (height: number) => void;
};
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
let value = String(raw || "").trim();
if (!value.length) return "";
// Replace non-breaking spaces to allow text wrap
value = value.replaceAll("&nbsp;", " ").replaceAll("\u00a0", " ");
if (value[0] === "<") return value;
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
}
function slugifyHeading(raw: string): string {
const input = String(raw || "").trim();
if (!input.length) return "";
return input
.toLowerCase()
.replace(/đ/g, "d")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 80);
}
function isExternalHref(href: string): boolean {
const h = href.trim().toLowerCase();
return (
h.startsWith("http://") ||
h.startsWith("https://") ||
h.startsWith("mailto:") ||
h.startsWith("tel:") ||
h.startsWith("sms:")
);
}
function extractWikiSlugFromHref(href: string): string {
const raw = String(href || "").trim();
if (!raw.length || raw === "__missing__") return "";
if (raw.startsWith("#wiki:")) return raw.slice("#wiki:".length).trim();
if (raw.startsWith("#")) return "";
const isAbsoluteUrl = /^[a-z][a-z\d+.-]*:/i.test(raw);
const baseOrigin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
if (isAbsoluteUrl) {
try {
const url = new URL(raw, baseOrigin);
if (typeof window !== "undefined" && url.origin !== window.location.origin) return "";
const path = url.pathname.replace(/\/+$/, "");
if (!path.startsWith("/wiki/")) return "";
return decodeWikiSlug(path.slice("/wiki/".length));
} catch {
return "";
}
}
const match = raw.match(/^([^?#]+)([?#].*)?$/);
let slug = String(match?.[1] || "").replace(/^\/+/, "").replace(/\/+$/, "").trim();
if (slug.startsWith("wiki/")) {
slug = slug.slice("wiki/".length).trim();
}
return decodeWikiSlug(slug);
}
function decodeWikiSlug(slug: string): string {
try {
return decodeURIComponent(slug).trim();
} catch {
return slug.trim();
}
}
function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
const parser = new DOMParser();
const doc = parser.parseFromString(inputHtml, "text/html");
for (const el of Array.from(doc.querySelectorAll("script"))) el.remove();
for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
const href = String(a.getAttribute("href") || "").trim();
if (!href.length) continue;
if (href === "__missing__") continue;
const slugPart = extractWikiSlugFromHref(href);
if (slugPart.length) {
a.setAttribute("href", `#wiki:${slugPart}`);
a.setAttribute("data-wiki-slug", slugPart);
a.setAttribute("target", "_self");
continue;
}
if (isExternalHref(href)) {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
}
}
const toc: TocItem[] = [];
const seen = new Map<string, number>();
const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6"));
for (const h of headings) {
const text = String(h.textContent || "").trim();
if (!text.length) continue;
const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1;
const existingId = String(h.getAttribute("id") || "").trim();
if (existingId) {
toc.push({ id: existingId, level, text });
continue;
}
const base = slugifyHeading(text) || "heading";
const nextCount = (seen.get(base) || 0) + 1;
seen.set(base, nextCount);
const id = nextCount === 1 ? base : `${base}-${nextCount}`;
h.setAttribute("id", id);
toc.push({ id, level, text });
}
return { html: doc.body.innerHTML, toc };
}
function getWikiContentScrollContainer(root: HTMLElement | null): HTMLElement | null {
return root?.closest<HTMLElement>(".uhm-public-wiki-sidebar-content") ?? null;
}
function PublicWikiSidebar({
entity,
wiki,
isLoading,
error,
onClose,
onWikiLinkRequest,
onWikiLinkEntitySelectionRequest,
sidebarWidth,
onSidebarWidthChange,
maxDragWidth,
compactHeader = false,
sidebarHeight,
onSidebarHeightChange,
}: Props) {
const contentRootRef = useRef<HTMLDivElement | null>(null);
const tocContainerRef = useRef<HTMLDivElement | null>(null);
const [wikiLinkMenu, setWikiLinkMenu] = useState<{
slug: string;
rect: DOMRect;
top: number;
left: number;
} | null>(null);
useEffect(() => {
import("react-quill-new/dist/quill.snow.css");
}, []);
const [localWidth, setLocalWidth] = useState<number>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("public-wiki-sidebar-width");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) {
return parsed;
}
}
}
return 420;
});
const width = sidebarWidth ?? localWidth;
const setWidth = onSidebarWidthChange ?? setLocalWidth;
const maxDragWidthLimit = maxDragWidth ?? 800;
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
// Tạo đường ghost ảo chỉ vị trí kéo thay vì kích hoạt re-render liên tục
const ghost = document.createElement("div");
ghost.style.position = "fixed";
ghost.style.top = "0";
ghost.style.bottom = "0";
ghost.style.width = "4px";
ghost.style.backgroundColor = "#38bdf8";
ghost.style.boxShadow = "0 0 12px rgba(56, 189, 248, 0.8)";
ghost.style.zIndex = "99999";
ghost.style.cursor = "col-resize";
ghost.style.pointerEvents = "none";
ghost.style.left = `${startX}px`;
document.body.appendChild(ghost);
const onMove = (e: PointerEvent) => {
ghost.style.left = `${e.clientX}px`;
};
const onUp = (e: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
if (ghost.parentNode) {
ghost.parentNode.removeChild(ghost);
}
const deltaX = e.clientX - startX;
const nextWidth = Math.max(320, Math.min(maxDragWidthLimit, startWidth - deltaX));
setWidth(nextWidth);
if (typeof window !== "undefined") {
localStorage.setItem("public-wiki-sidebar-width", String(nextWidth));
}
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const processedWiki = useMemo(() => {
if (!wiki) return { html: "", toc: [] as TocItem[] };
const html = normalizeWikiContentToHtml(wiki.content ?? "");
try {
return prepareWikiHtml(html);
} catch (err) {
console.error("Failed to process sidebar wiki HTML", err);
return { html, toc: [] as TocItem[] };
}
}, [wiki]);
const renderHtml = processedWiki.html;
const toc = processedWiki.toc;
useLayoutEffect(() => {
const scrollContainer = getWikiContentScrollContainer(contentRootRef.current);
scrollContainer?.scrollTo({ top: 0, behavior: "auto" });
tocContainerRef.current?.scrollTo({ left: 0, behavior: "auto" });
}, [wiki?.id, wiki?.slug, renderHtml, toc]);
useEffect(() => {
const container = tocContainerRef.current;
if (!container) return;
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0) {
e.preventDefault();
container.scrollLeft += e.deltaY;
}
};
container.addEventListener("wheel", handleWheel, { passive: false });
return () => {
container.removeEventListener("wheel", handleWheel);
};
}, [toc]);
useEffect(() => {
const root = contentRootRef.current;
if (!root) return;
const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
const fallbackLink = target?.closest?.("a[href]") as HTMLAnchorElement | null;
const sourceLink = link || fallbackLink;
if (!sourceLink) return;
const slug = String(
sourceLink.getAttribute("data-wiki-slug") ||
extractWikiSlugFromHref(sourceLink.getAttribute("href") || "")
).trim();
if (!slug.length) return;
event.preventDefault();
onWikiLinkRequest({ slug, rect: sourceLink.getBoundingClientRect() });
};
root.addEventListener("click", handleClick);
return () => root.removeEventListener("click", handleClick);
}, [onWikiLinkRequest, renderHtml]);
useEffect(() => {
const root = contentRootRef.current;
if (!root) return;
const handleContextMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
const fallbackLink = target?.closest?.("a[href]") as HTMLAnchorElement | null;
const sourceLink = link || fallbackLink;
if (!sourceLink) return;
const slug = String(
sourceLink.getAttribute("data-wiki-slug") ||
extractWikiSlugFromHref(sourceLink.getAttribute("href") || "")
).trim();
if (!slug.length) return;
event.preventDefault();
setWikiLinkMenu({
slug,
rect: sourceLink.getBoundingClientRect(),
...computeContextMenuPosition(event.clientX, event.clientY, 220, 88),
});
};
root.addEventListener("contextmenu", handleContextMenu, true);
return () => root.removeEventListener("contextmenu", handleContextMenu, true);
}, [renderHtml]);
useEffect(() => {
if (!wikiLinkMenu) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest?.("[data-wiki-link-context-menu='true']")) return;
setWikiLinkMenu(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setWikiLinkMenu(null);
};
const closeMenu = () => setWikiLinkMenu(null);
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", closeMenu);
window.addEventListener("scroll", closeMenu, true);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", closeMenu);
window.removeEventListener("scroll", closeMenu, true);
};
}, [wikiLinkMenu]);
const handleOpenStandaloneWiki = (slug: string) => {
if (typeof window === "undefined") return;
const url = `/wiki/${encodeURIComponent(slug)}`;
const nextWindow = window.open(url, "_blank", "noopener,noreferrer");
if (nextWindow) nextWindow.opener = null;
};
const isExpanded = useMemo(() => {
if (typeof window === "undefined") return false;
const fullHeight = Math.round(window.innerHeight * 0.70);
return (sidebarHeight || 400) >= fullHeight;
}, [sidebarHeight]);
const handleHeightToggle = () => {
if (typeof window === "undefined") return;
const halfHeight = Math.round(window.innerHeight * 0.45);
const fullHeight = Math.round(window.innerHeight * 0.85);
const currentHeight = sidebarHeight || 400;
const nextHeight = Math.abs(currentHeight - halfHeight) < Math.abs(currentHeight - fullHeight)
? fullHeight
: halfHeight;
if (onSidebarHeightChange) {
onSidebarHeightChange(nextHeight);
}
};
const [isMobileOrTablet, setIsMobileOrTablet] = useState(false);
useEffect(() => {
const checkDevice = () => setIsMobileOrTablet(window.innerWidth < 1024);
checkDevice();
window.addEventListener("resize", checkDevice);
return () => window.removeEventListener("resize", checkDevice);
}, []);
return (
<div
style={{
width: isMobileOrTablet ? "100%" : `${width}px`,
maxWidth: "100%",
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
overflow: "hidden",
borderRadius: isMobileOrTablet ? "24px 24px 0 0" : 20,
border: isMobileOrTablet ? "1px solid rgba(148, 163, 184, 0.22)" : "1px solid rgba(148, 163, 184, 0.22)",
borderBottom: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
borderLeft: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
borderRight: isMobileOrTablet ? "none" : "1px solid rgba(148, 163, 184, 0.22)",
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.85))",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)",
position: "relative",
}}
>
{/* Grab Handle for bottom sheet on mobile */}
{isMobileOrTablet ? (
<div
onClick={handleHeightToggle}
style={{
width: "100%",
height: 28,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
zIndex: 60,
userSelect: "none",
flexShrink: 0,
gap: 8,
}}
>
<div
style={{
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: "rgba(255, 255, 255, 0.3)",
}}
/>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255, 255, 255, 0.5)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.3s ease",
}}
>
<polyline points="18 15 12 9 6 15" />
</svg>
</div>
) : null}
{/* Drag Handle on the left edge */}
<div
onPointerDown={handlePointerDown}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 6,
cursor: "col-resize",
zIndex: 50,
userSelect: "none",
}}
className="group hidden lg:block"
title="Kéo để chỉnh kích thước"
>
{/* Visual drag line overlay */}
<div
style={{
position: "absolute",
left: 2,
top: 0,
bottom: 0,
width: 2,
background: "transparent",
transition: "background-color 0.2s",
}}
className="group-hover:bg-sky-500/50 group-active:bg-sky-500"
/>
</div>
<div
style={{
borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
padding: "16px",
}}
>
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 12 }}>
<div style={{ minWidth: 0, flex: 1 }}>
{compactHeader ? null : (
<div
style={{
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "1.2px",
fontWeight: 900,
color: "#94a3b8",
}}
>
Wiki
</div>
)}
<div
style={{
marginTop: compactHeader ? 0 : 4,
fontSize: 18,
fontWeight: 700,
lineHeight: 1.3,
color: "#f8fafc",
}}
>
{wiki?.title?.trim() || entity?.name?.trim() || "Wiki"}
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
display: "inline-flex",
height: 28,
width: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
border: "1px solid rgba(148, 163, 184, 0.25)",
background: "rgba(30, 41, 59, 0.4)",
color: "#94a3b8",
cursor: "pointer",
fontSize: 12,
transition: "all 0.2s",
outline: "none",
}}
className="hover:bg-slate-700/50 hover:text-slate-100"
aria-label="Đóng khung wiki"
>
x
</button>
</div>
</div>
{toc.length ? (
<div
style={{
borderBottom: "1px solid rgba(148, 163, 184, 0.15)",
padding: "8px 12px",
}}
>
<div
ref={tocContainerRef}
className="uhm-public-wiki-toc-list"
style={{
display: "flex",
gap: 8,
overflowX: "auto",
paddingBottom: 4,
}}
>
{toc.slice(0, 8).map((item) => {
return (
<a
key={item.id}
href={`#${item.id}`}
onClick={(e) => {
e.preventDefault();
const root = contentRootRef.current;
if (root) {
const targetElement = root.querySelector(`#${CSS.escape(item.id)}`) as HTMLElement | null;
const scrollContainer = getWikiContentScrollContainer(root);
if (targetElement && scrollContainer) {
const containerTop = scrollContainer.getBoundingClientRect().top;
const targetTop = targetElement.getBoundingClientRect().top;
const scrollOffset = targetTop - containerTop + scrollContainer.scrollTop;
scrollContainer.scrollTo({
top: scrollOffset - 12,
behavior: "smooth"
});
}
}
}}
style={{
flexShrink: 0,
borderRadius: 9999,
padding: "4px 10px",
fontSize: 11,
fontWeight: 650,
textDecoration: "none",
transition: "all 0.2s",
background: "rgba(30, 41, 59, 0.4)",
color: "#94a3b8",
border: "1px solid rgba(148, 163, 184, 0.1)",
}}
className="hover:bg-slate-700/40 hover:text-slate-200"
>
{item.text}
</a>
);
})}
</div>
</div>
) : null}
<div
className="uhm-public-wiki-sidebar-content"
style={{
minHeight: 0,
flex: 1,
overflowY: "auto",
}}
>
{isLoading && !wiki ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 16 }}>
<div
style={{ height: 16, width: 110, borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
className="animate-pulse"
/>
<div
style={{ height: 16, width: "100%", borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
className="animate-pulse"
/>
<div
style={{ height: 16, width: "80%", borderRadius: 4, background: "rgba(148, 163, 184, 0.15)" }}
className="animate-pulse"
/>
</div>
) : error ? (
<div style={{ padding: 16, fontSize: 14, color: "#f87171" }}>
{error}
</div>
) : wiki ? (
<div style={{ position: "relative", minHeight: "100%" }}>
{isLoading ? (
<div
aria-hidden="true"
style={{
position: "sticky",
top: 0,
left: 0,
right: 0,
height: 2,
zIndex: 2,
overflow: "hidden",
background: "rgba(56, 189, 248, 0.08)",
}}
>
<div
className="uhm-wiki-sidebar-loading-bar"
style={{
height: "100%",
width: "42%",
background: "linear-gradient(90deg, transparent, #38bdf8, transparent)",
}}
/>
</div>
) : null}
<div
ref={contentRootRef}
className="uhm-wiki-sidebar-view ql-editor"
style={{ fontSize: 14, color: "#cbd5e1" }}
dangerouslySetInnerHTML={{ __html: renderHtml }}
/>
</div>
) : (
<div style={{ padding: 16, fontSize: 14, color: "#94a3b8" }}>
Entity này chưa wiki liên kết.
</div>
)}
</div>
{wikiLinkMenu && typeof document !== "undefined"
? createPortal(
<div
data-wiki-link-context-menu="true"
style={{
position: "fixed",
top: wikiLinkMenu.top,
left: wikiLinkMenu.left,
zIndex: 100000,
width: 220,
overflow: "hidden",
borderRadius: 10,
border: "1px solid rgba(148, 163, 184, 0.28)",
background: "rgba(15, 23, 42, 0.98)",
boxShadow: "0 18px 42px rgba(2, 6, 23, 0.48)",
color: "#e2e8f0",
padding: 6,
}}
>
<button
type="button"
onClick={() => {
handleOpenStandaloneWiki(wikiLinkMenu.slug);
setWikiLinkMenu(null);
}}
style={wikiLinkMenuButtonStyle}
className="hover:bg-sky-500/15 hover:text-sky-100"
>
Mở wiki riêng
</button>
<button
type="button"
onClick={() => {
const request = { slug: wikiLinkMenu.slug, rect: wikiLinkMenu.rect };
if (onWikiLinkEntitySelectionRequest) {
onWikiLinkEntitySelectionRequest(request);
} else {
onWikiLinkRequest(request);
}
setWikiLinkMenu(null);
}}
style={wikiLinkMenuButtonStyle}
className="hover:bg-sky-500/15 hover:text-sky-100"
>
Mở bảng chọn entity
</button>
</div>,
document.body
)
: null}
<style jsx global>{`
.uhm-public-wiki-sidebar-content::-webkit-scrollbar {
width: 6px;
}
.uhm-public-wiki-sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.uhm-public-wiki-sidebar-content::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.22);
border-radius: 3px;
}
.uhm-public-wiki-sidebar-content::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.4);
}
.uhm-public-wiki-toc-list::-webkit-scrollbar {
height: 4px;
}
.uhm-public-wiki-toc-list::-webkit-scrollbar-track {
background: transparent;
}
.uhm-public-wiki-toc-list::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.22);
border-radius: 2px;
}
.uhm-public-wiki-toc-list::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.4);
}
@keyframes uhm-wiki-sidebar-loading-bar {
from {
transform: translateX(-120%);
}
to {
transform: translateX(260%);
}
}
.uhm-wiki-sidebar-loading-bar {
animation: uhm-wiki-sidebar-loading-bar 1.1s ease-in-out infinite;
}
.uhm-wiki-sidebar-view.ql-editor {
height: auto;
overflow-y: visible;
padding: 18px 18px 22px;
line-height: 1.6;
font-size: 14.5px;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
color: #cbd5e1 !important;
}
.uhm-wiki-sidebar-view.ql-editor p {
margin: 0 0 0.75em;
}
.uhm-wiki-sidebar-view.ql-editor h1 {
margin: 1.15em 0 0.6em;
font-size: 1.6em;
font-weight: 800;
line-height: 1.2;
color: #f8fafc !important;
}
.uhm-wiki-sidebar-view.ql-editor h2 {
margin: 1.05em 0 0.55em;
font-size: 1.3em;
font-weight: 800;
line-height: 1.25;
color: #f8fafc !important;
}
.uhm-wiki-sidebar-view.ql-editor h3,
.uhm-wiki-sidebar-view.ql-editor h4,
.uhm-wiki-sidebar-view.ql-editor h5,
.uhm-wiki-sidebar-view.ql-editor h6 {
margin: 0.95em 0 0.45em;
font-size: 1.05em;
font-weight: 700;
line-height: 1.3;
color: #f8fafc !important;
}
.uhm-wiki-sidebar-view.ql-editor ul,
.uhm-wiki-sidebar-view.ql-editor ol {
margin: 0 0 0.75em;
padding-left: 1.5em;
}
.uhm-wiki-sidebar-view.ql-editor blockquote {
margin: 0 0 0.75em;
padding-left: 12px;
border-left: 3px solid rgba(148, 163, 184, 0.4);
color: rgba(203, 213, 225, 0.95);
}
.uhm-wiki-sidebar-view.ql-editor pre {
margin: 0 0 0.75em;
padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 10px;
background: rgba(15, 23, 42, 0.4);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
color: #cbd5e1;
}
.uhm-wiki-sidebar-view.ql-editor img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.uhm-wiki-sidebar-view.ql-editor img[style*="float: left"],
.uhm-wiki-sidebar-view.ql-editor img.ql-align-left {
float: left !important;
margin: 4px 14px 14px 0px !important;
display: inline !important;
}
.uhm-wiki-sidebar-view.ql-editor img[style*="float: right"],
.uhm-wiki-sidebar-view.ql-editor img.ql-align-right {
float: right !important;
margin: 4px 0px 14px 14px !important;
display: inline !important;
}
.uhm-wiki-sidebar-view.ql-editor img[style*="display: block"],
.uhm-wiki-sidebar-view.ql-editor img.ql-align-center {
display: block !important;
margin: 1.25em auto !important;
}
.uhm-wiki-sidebar-view.ql-editor a {
text-decoration: underline;
text-underline-offset: 2px;
}
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
color: #38bdf8 !important;
transition: color 0.15s ease;
}
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]):hover {
color: #7dd3fc !important;
}
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
cursor: default;
pointer-events: none;
}
.uhm-wiki-sidebar-view.ql-editor a:not([href]),
.uhm-wiki-sidebar-view.ql-editor a[href=""],
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
color: #f87171 !important;
}
@media (max-width: 640px) {
.uhm-wiki-sidebar-view.ql-editor {
padding: 14px 14px 20px;
font-size: 13.5px;
}
.uhm-wiki-sidebar-view.ql-editor h1 {
font-size: 1.4em;
}
.uhm-wiki-sidebar-view.ql-editor h2 {
font-size: 1.2em;
}
}
`}</style>
</div>
);
}
const wikiLinkMenuButtonStyle = {
display: "block",
width: "100%",
border: 0,
borderRadius: 7,
background: "transparent",
padding: "9px 10px",
color: "inherit",
cursor: "pointer",
fontSize: 13,
fontWeight: 700,
lineHeight: "18px",
textAlign: "left" as const,
};
function computeContextMenuPosition(clientX: number, clientY: number, width: number, height: number) {
const margin = 8;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
return {
left: Math.max(margin, Math.min(clientX, viewportWidth - width - margin)),
top: Math.max(margin, Math.min(clientY, viewportHeight - height - margin)),
};
}
export default memo(PublicWikiSidebar);
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
# Editor (/editor) - Local Store & Snapshot Conversion
Tài liệu này mô tả chi tiết **các nơi lưu trữ state (store) ở phía FrontEndUser** trong `/editor/[id]`, ý nghĩa từng biến state, state nào là “single source of truth”, state nào chỉ là cache/UI, và cách chuyển đổi qua lại giữa:
1. **Local session state** (React state trong phiên làm việc)
2. **Commit snapshot** (`commits.snapshot_json`)
3. **Reload trang** (mất state local, load lại từ commit snapshot)
Mục tiêu: dễ debug, nhất quán dữ liệu, tránh sai semantics `"reference"`/`"binding"`.
---
## 0) 5 Dataset Quan Trọng Nhất (GEO/ENT/WIKI/ENT_WIKI/GEO_ENT)
Trong `/editor`, 5 nhóm dữ liệu quan trọng nhất tương ứng trực tiếp với snapshot:
1. **GEO**: `snapshot_json.geometries[]` + `snapshot_json.editor_feature_collection`
2. **ENT**: `snapshot_json.entities[]`
3. **WIKI**: `snapshot_json.wikis[]`
4. **ENT_WIKI** (entity ↔ wiki): `snapshot_json.entity_wiki[]`
5. **GEO_ENT** (geometry ↔ entity): `snapshot_json.geometry_entity[]`
Điểm quan trọng về “store”:
- **ENT/WIKI/ENT_WIKI** có store snapshot riêng trong React session:
- `snapshotEntities` -> `entities[]`
- `snapshotWikis` -> `wikis[]`
- `snapshotEntityWikiLinks` -> `entity_wiki[]`
- **GEO/GEO_ENT không có store snapshot riêng theo kiểu `snapshotGeometries` / `snapshotGeometryEntity`**.
- Trong session, GEO sống ở **`editor.draft`** (GeoJSON FeatureCollection).
- Khi commit, FE **build ra**:
- `geometries[]` từ `editor.draft + editor.changes + baselineSnapshot.geometries`
- `geometry_entity[]` từ `editor.draft.features[].properties.entity_ids`
Vì vậy, nếu bạn “tìm store của geo trong React state” thì bạn sẽ thấy nó nằm ở `useEditorState()` chứ không nằm trong `useEditorSessionState()`.
---
## 1) Nguyên tắc chung
### 1.1 Single source of truth theo lớp
- **Geometry (map/editor):** `useEditorState(initialData)` là state trung tâm cho `draft/changes/undo`.
- **Snapshot stores (phần sẽ đi vào commit snapshot):**
- `snapshotEntities` -> `snapshot_json.entities`
- `snapshotWikis` -> `snapshot_json.wikis`
- `snapshotEntityWikiLinks` -> `snapshot_json.entity_wiki`
- **Catalog/cache để tìm kiếm & hiển thị:**
- `entityCatalog` là danh sách entity “global” trong RAM (fetch + search merge). Không phải snapshot.
### 1.2 “reference” vs “binding”
- `"reference"` (entities/wikis/geometries.operation) nghĩa là **không sửa record** trong commit đó.
- `"binding"` (chỉ áp dụng cho `entity_wiki.operation`) nghĩa là **link entity ↔ wiki đang tồn tại** trong snapshot.
- `"delete"` nghĩa là xóa record (entities/wikis/geometries) hoặc unlink (entity_wiki).
Khi **mở 1 phiên editor mới từ commit**, mọi operation local đều bị “reset về baseline”:
- `entities[].operation``wikis[].operation` trong session -> `"reference"`
- `entity_wiki[].operation` trong session -> `"binding"` (nếu link còn active)
---
## 2) Local state: danh sách đầy đủ và ý nghĩa
Các state này được tạo từ `useEditorSessionState()``useEditorState()` trong:
- `FrontEndUser/src/app/editor/[id]/page.tsx`
- `FrontEndUser/src/uhm/lib/useEditorSessionState.ts`
- `FrontEndUser/src/uhm/lib/useEditorState.ts`
### 2.1 Geometry editor state (core)
Nguồn: `const editor = useEditorState(initialData)`
- `initialData: FeatureCollection`
-**baseline** của session hiện tại để render Map ban đầu.
- Được set khi:
- mở project (load snapshot head),
- restore FE-only từ 1 commit,
- hoặc import/replace dữ liệu session.
- `editor.draft: FeatureCollection`
- **Single source of truth** cho geometry đang hiển thị + chỉnh sửa.
- Map render trực tiếp từ `draft` (hoặc bản “visibleDraft” đã filter theo timeline/bound_with).
- Đây chính là **store runtime của GEO** trong session.
- `editor.changes: Map<id, Change>`
- Diff giữa `draft` và baseline map nội bộ (initialMapRef).
- Dùng để tính `pendingSaveCount` và để build snapshot geometries/update/delete.
- `editor.undoStack`
- Danh sách thao tác gần nhất (create/update/properties/delete).
- `editor.changeCount`
- Số lượng changes (để chặn commit khi không đổi gì).
- `editor.hasPersistedFeature(id)`
- `true` nếu feature đã tồn tại trong baseline map nội bộ.
- Dùng để phân biệt geometry mới khi build snapshot và hiển thị trạng thái `new`.
### 2.2 Snapshot stores (persisted on commit)
Các state này là “source of truth” cho những phần non-geometry trong commit snapshot.
#### a) `snapshotEntities: EntitySnapshot[]`
- Dùng để build `snapshot_json.entities`.
- Bao gồm:
- entity “pin” vào project (`source:"ref"`, `operation:"reference"`),
- entity tạo mới local (`source:"inline"`, `operation:"create"`),
- entity bị xóa (nếu có) (`operation:"delete"`).
Lưu ý quan trọng:
- `snapshotEntities` là nơi “giữ entity” **qua các commit**, kể cả entity tạo mới chưa bind geometry.
- `buildEditorSnapshot()` có logic carry-forward inline entity từ `previousSnapshot` để tránh mất entity sau commit/reload.
#### b) `snapshotWikis: WikiSnapshot[]`
- Dùng để build `snapshot_json.wikis`.
- Wiki hiện lưu `doc`**string (HTML)** (Quill) hoặc `null` với ref wiki.
- Tiptap JSON cũ: được normalize sang HTML để hiển thị.
#### c) `snapshotEntityWikiLinks: EntityWikiLinkSnapshot[]`
- Dùng để build `snapshot_json.entity_wiki`.
- `operation`:
- `"binding"`: link đang tồn tại
- `"delete"`: unlink trong snapshot
- (compat) `"reference"` từ snapshot cũ được normalize thành `"binding"` khi load.
### 2.3 Catalog/cache state (không persist)
#### `entityCatalog: Entity[]`
Đây là **RAM cache** để:
- hiển thị tên/description/status của entity,
- merge kết quả fetch + search,
- giảm tình trạng UI “cùng 1 entity nhưng 2 object khác nhau”.
Không ghi thẳng vào snapshot. Snapshot vẫn lấy từ `snapshotEntities`.
Trong page, danh sách `entities` dùng cho UI được merge:
`entities = mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities)`
Nghĩa là: snapshot entities (local) luôn được ưu tiên hiển thị trong UI.
### 2.4 UI-only state (không persist)
Các state sau chỉ phục vụ UX, mất khi reload:
- `mode` (idle/select/add-*)
- `selectedFeatureId`
- `selectedGeometryEntityIds` (list bind tạm thời cho UI, map patch sẽ sync vào feature properties)
- `geometryMetaForm`
- `entityForm` (tạo entity mới)
- `entityFormStatus` (toast/status 3s)
- `searchKind`, `searchQuery`
- `entitySearchResults`, `wikiSearchResults`, `geoSearchResults`
- `timelineDraftYear`, `timelineFilterEnabled`
- panel widths (`leftPanelWidth`, `rightPanelWidth`)
### 2.5 LocalStorage (trên browser)
Hiện tại chỉ có **1 thứ** persist sang LocalStorage:
- `backgroundVisibility` (ẩn/hiện layer nền)
Các snapshot stores (`snapshotEntities`, `snapshotWikis`, `snapshotEntityWikiLinks`, `draft`) **không** lưu LocalStorage; chúng được persist qua commit snapshot (backend).
---
## 3) Chuyển đổi giữa local session ↔ snapshot
### 3.1 Load snapshot -> mở session
Luồng: `openSectionEditor()` -> `normalizeEditorSnapshot()` -> `toEditorSessionSnapshot()`
Khi mở session mới:
1. `baselineSnapshot = toEditorSessionSnapshot(snapshot)`
2. `initialData = baselineSnapshot.editor_feature_collection || EMPTY_FEATURE_COLLECTION`
3. `snapshotEntities = baselineSnapshot.entities || []`
4. `snapshotWikis = baselineSnapshot.wikis || []`
5. `snapshotEntityWikiLinks = baselineSnapshot.entity_wiki || []`
Riêng về GEO/GEO_ENT khi load:
- `baselineSnapshot.editor_feature_collection` là dữ liệu map gốc đưa vào `initialData`.
- `normalizeEditorSnapshot()` sẽ **rehydrate** `feature.properties.entity_ids/entity_id` từ `snapshot.geometry_entity[]` (hoặc legacy `link_scopes`) để UI bind entity hoạt động.
- Lưu ý: đây là rehydrate phục vụ editor UX, **không phải** dữ liệu persist chính thức trên `feature.properties` trong snapshot.
Điểm mấu chốt: **toEditorSessionSnapshot() reset operation** để snapshot trở thành “baseline state”:
- entities/wikis -> `"reference"`
- entity_wiki active -> `"binding"`
### 3.2 Commit session -> snapshot_json
Luồng: `commitSection()` -> `buildEditorSnapshot({ draft, changes, snapshotEntities, snapshotWikis, snapshotEntityWikiLinks, previousSnapshot: baselineSnapshot })`
`buildEditorSnapshot()` sẽ tạo:
- `editor_feature_collection` (draft đã strip các field denormalized)
- `geometries[]` (create/update/delete dựa trên changes + previousSnapshot)
- `geometry_entity[]` (join table từ feature.properties.entity_ids)
- `entities[]` (từ snapshotEntities + carry-forward inline + ensure entities referenced by joins)
- `wikis[]` (từ snapshotWikis, tương tự)
- `entity_wiki[]` (từ snapshotEntityWikiLinks, đã dedupe/sort)
Sau khi commit thành công:
- `baselineSnapshot` cập nhật = `toEditorSessionSnapshot(snapshot)` của commit mới
- snapshot stores cập nhật theo baseline mới (operation reset về `"reference"/"binding"`)
### 3.3 Reload trang -> mất local state
Khi reload:
- Toàn bộ React state reset
- App sẽ load lại snapshot từ backend (head commit)
- Các thứ bạn “tạo/sửa” chỉ còn lại nếu đã nằm trong commit snapshot
Vì vậy:
- Entity/Wiki/Link/Geometry muốn “không mất” phải đi qua **Commit**.
- Các state UI (selected geo, search results, form đang nhập) sẽ mất.
---
## 4) GEO Search (`/geometries/entity`) và tác động lên local store
Search GEO gọi:
`GET /geometries/entity?name=<keyword>&limit=<n>`
Khi bấm **Import** một geometry từ kết quả search:
1. Giữ nguyên `timelineFilterEnabled`; geometry import vẫn tuân theo filter năm hiện tại.
2. Add entity tương ứng vào:
- `snapshotEntities` (source:"ref", operation:"reference")
- `entityCatalog` (để UI có name/description)
3. Nếu geometry chưa có trong `editor.draft`:
- tạo `Feature` mới với `id = geometry.id`
- set `properties.type` từ `geo_type` (map qua `geoTypeCodeToTypeKey`)
- set `time_start/time_end/bound_with`
- set denormalized `entity_id/entity_ids/entity_name/entity_names` để UI/joins hoạt động
4. `editor.createFeature(feature)` và auto select feature đó.
Lưu ý: Import geo tạo ra “create change” trong editor session, nên sẽ đi vào commit snapshot.
---
## 4.1 Nhìn nhanh “5 dataset nằm ở đâu” trong session
- GEO:
- Runtime store: `editor.draft.features[]`
- Persisted on commit: `snapshot_json.geometries[]` (build khi commit)
- ENT:
- Runtime store (snapshot): `snapshotEntities`
- Persisted on commit: `snapshot_json.entities[]`
- WIKI:
- Runtime store (snapshot): `snapshotWikis`
- Persisted on commit: `snapshot_json.wikis[]`
- ENT_WIKI:
- Runtime store (snapshot): `snapshotEntityWikiLinks`
- Persisted on commit: `snapshot_json.entity_wiki[]`
- GEO_ENT:
- Runtime store: denormalized tạm thời trên `editor.draft.features[].properties.entity_ids` (để UI chạy)
- Persisted on commit: `snapshot_json.geometry_entity[]` (build khi commit)
---
## 5) Checklist khi debug “mất dữ liệu”
1. Dữ liệu có nằm trong `snapshotEntities/snapshotWikis/snapshotEntityWikiLinks/editor.draft` không?
2. Có bấm **Commit** chưa?
3. `pendingSaveCount` có > 0 không (Commit button có enable không)?
4. Khi reload, snapshot head commit load lên có chứa các rows đó không?
5. Nếu entity tạo mới bị mất:
- kiểm tra commit snapshot có `entities[].source:"inline"` không
- nếu có mà reload vẫn mất, kiểm tra `normalizeEditorSnapshot()` có parse đúng không
---
## 6) File/entrypoints liên quan
- Session stores:
- `FrontEndUser/src/uhm/lib/useEditorSessionState.ts`
- `FrontEndUser/src/uhm/lib/editor/session/useEntitySessionState.ts`
- `FrontEndUser/src/uhm/lib/editor/session/useWikiSessionState.ts`
- `FrontEndUser/src/uhm/lib/editor/session/useSectionSessionState.ts`
- Geometry editor core:
- `FrontEndUser/src/uhm/lib/useEditorState.ts`
- Snapshot normalization + build snapshot:
- `FrontEndUser/src/uhm/lib/editor/snapshot/editorSnapshot.ts`
- Open/commit/restore commands:
- `FrontEndUser/src/uhm/lib/editor/section/useSectionCommands.ts`
- Page wiring / UI state:
- `FrontEndUser/src/app/editor/[id]/page.tsx`
+72
View File
@@ -0,0 +1,72 @@
# Thuật toán Băm Màu sắc từ ID (Color Hashing Algorithm)
Tài liệu này mô tả chi tiết giải thuật băm chuỗi định danh (ID) thành mã màu sắc HSL trong ứng dụng bản đồ lịch sử, nhằm giải quyết vấn đề trùng lặp màu sắc hiển thị giữa các thực thể/hình học.
---
## 1. Vấn đề thực tế (Problem Statement)
Trong các phiên bản trước, hàm băm chuỗi thành màu sử dụng giải thuật cộng dồn mã ký tự đơn giản:
$$\text{hash} = \sum \text{char} + ((\text{hash} \ll 5) - \text{hash})$$
Với độ bão hòa (Saturation) và độ sáng (Lightness) cố định ở mức `70%``50%`.
Cách tiếp cận này gặp phải điểm yếu nghiêm trọng khi xử lý **định danh tuần tự** (sequential IDs) hoặc các chuỗi ID gần giống nhau (ví dụ: các ID tự tăng như `1`, `2`, `3` hoặc các UUID chỉ khác nhau ký tự cuối):
* Giải thuật băm cũ sinh ra các giá trị băm liên tiếp nhau (ví dụ: `1001`, `1002`, `1003`).
* Khi chia lấy dư cho $360$ để tìm góc màu Hue, kết quả cho ra các góc màu liền kề (ví dụ: $201^\circ$, $202^\circ$, $203^\circ$).
* Đối với mắt người, các góc màu quá sát nhau này hoàn toàn không thể phân biệt được, dẫn đến việc các quốc gia/vùng lãnh thổ/tuyến đường cạnh nhau bị hiển thị trùng một màu, gây hiểu nhầm dữ liệu lịch sử.
---
## 2. Giải pháp & Thuật toán Nâng cấp (Proposed Solution)
Để giải quyết triệt để vấn đề này, thuật toán mới đã được cải tiến thông qua hai kỹ thuật chính:
### A. Phân tán giá trị băm của Knuth (Knuth's Multiplicative Hashing)
Sau bước băm ký tự ban đầu bằng DJB2 nâng cao (sử dụng XOR), giá trị băm sẽ được nhân với hằng số vàng của Knuth:
$$A = 2654435761 \quad (\approx 2^{32} \times \frac{\sqrt{5} - 1}{2})$$
Hằng số này hoạt động như một bộ xáo trộn bit (bit mixer). Hai giá trị băm ban đầu đứng cạnh nhau sau khi nhân với $2654435761$ và lấy trị tuyệt đối sẽ được phân tán đều khắp không gian số nguyên 32-bit. Điều này đảm bảo góc màu Hue giữa hai ID kề nhau sẽ có độ tương phản cực kỳ cao (ví dụ: góc màu lệch nhau từ $30^\circ$ tới $180^\circ$).
### B. Biến thiên Độ bão hòa (Saturation) và Độ sáng (Lightness)
Thay vì cố định cứng $S = 70\%$ và $L = 50\%$, hai tham số này cũng được tính toán động từ giá trị băm phân tán:
* **Saturation ($S$):** Dao động ngẫu nhiên trong khoảng $[70\%, 90\%]$.
* **Lightness ($L$):** Dao động ngẫu nhiên trong khoảng $[45\%, 60\%]$.
Điều này giúp mở rộng không gian màu từ 1 chiều (chỉ thay đổi Hue) lên 3 chiều (thay đổi cả Hue, Saturation và Lightness), tạo ra hàng ngàn biến thể màu sắc độc nhất.
---
## 3. Mã Nguồn Triển khai (Implementation Code)
Hàm băm được đặt tại [mapUtils.ts](../components/map/mapUtils.ts):
```typescript
export function hashStringToColor(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
// Sử dụng hằng số nhân của Knuth để phân tán các mã băm kề nhau
const scattered = Math.abs(hash * 2654435761);
const hue = scattered % 360;
// Tự động biến thiên nhẹ độ bão hòa và độ sáng để tăng độ đa dạng màu
const saturation = 70 + (scattered % 20); // 70% đến 90%
const lightness = 45 + ((scattered >> 5) % 15); // 45% đến 60%
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
```
---
## 4. Ứng dụng trong Bản đồ (Map Application Context)
Hàm này được gọi tự động trong bộ lọc dữ liệu địa lý nhằm gán màu sắc trực quan cho các hình học không có màu chỉ định sẵn:
* **Tuyến đường (Lines):** Gộp các `entity_ids` thành một chuỗi duy nhất, sắp xếp theo thứ tự bảng chữ cái để đảm bảo tính nhất quán, sau đó băm thành màu sắc của tuyến đường.
* **Lãnh thổ/Vùng (Polygons):** Băm trực tiếp từ `geometry_id` của bản vẽ nháp hoặc thực thể để mỗi quốc gia/lãnh thổ có một màu sắc ranh giới trực quan riêng biệt.
---
## 5. Ưu điểm nổi bật (Key Benefits)
1. **Độ tương phản cao (High Contrast):** Các thực thể có ID tuần tự nằm cạnh nhau trên bản đồ luôn hiển thị màu sắc tương phản rõ rệt.
2. **Nhất quán (Deterministic):** Cùng một ID chuỗi đầu vào sẽ luôn trả về chính xác một mã màu duy nhất ở mọi thời điểm tải trang.
3. **Thẩm mỹ hiện đại (Modern Aesthetics):** Giới hạn độ sáng trong khoảng $45\% - 60\%$ giúp giữ cho màu sắc luôn rực rỡ (neon-like), không bị quá tối ẩn vào nền bản đồ, cũng không bị quá sáng làm mất đi tính thẩm mỹ của giao diện tối (dark theme).
+69
View File
@@ -0,0 +1,69 @@
# UHM Editor - Luồng Dữ Liệu Commit và Load Snapshot (FrontEnd)
Tài liệu này giải thích chi tiết vòng đời của dữ liệu liên kết (binding) giữa **Geometry****Entity** trên FrontEnd (FE), từ lúc người dùng thao tác trên giao diện, đóng gói gửi lên API (Commit), cho đến khi tải ngược lại từ API về Editor (Load).
---
## 1. Sơ đồ tổng quan luồng dữ liệu
```mermaid
graph TD
A[UI: Click Bind/Unbind] -->|Cập nhật| B(Editor Memory State: properties.entity_ids)
B -->|Bấm Commit| C(buildEditorSnapshot)
C -->|1. Xóa entity_ids khỏi GeoJSON<br>2. So sánh Baseline để tính operation: binding/delete/reference| D(Snapshot JSON phẳng)
D -->|POST /projects/:id/commits| E[Backend: Lưu thô JSON]
E -->|Mở Editor / Pull Commit| F(toEditorSessionSnapshot & normalizeEditorSnapshot)
F -->|1. Lọc bỏ các dòng operation = delete<br>2. Ghép ngược entity_id vào GeoJSON properties| G(Editor Memory State mới)
```
---
## 2. Các giai đoạn chi tiết
### Giai đoạn 1: Editor Runtime State (Đang chỉnh sửa)
Khi người dùng đang mở Editor, dữ liệu liên kết được lưu trực tiếp trong thuộc tính của đối tượng hình học (GeoJSON Feature Properties) để hiển thị nhanh trên bản đồ:
- `feature.properties.entity_ids`: Mảng chứa các ID của thực thể đang liên kết (Ví dụ: `["entity-uuid-1", "entity-uuid-2"]`).
- `feature.properties.entity_id`: ID của thực thể chính (thường là phần tử đầu tiên).
### Giai đoạn 2: Trước khi gửi Commit (Build Snapshot)
Để đảm bảo cơ sở dữ liệu snapshot gọn nhẹ và không bị dư thừa dữ liệu (normalized database), hàm `buildEditorSnapshot()` sẽ thực hiện hai việc quan trọng:
1. **Xóa thuộc tính liên kết trong GeoJSON:**
Trước khi lưu `editor_feature_collection`, FE sẽ **xóa sạch** các trường động như `entity_id`, `entity_ids`, `entity_name`, `entity_names` khỏi thuộc tính của Feature. Do đó, phần GeoJSON lưu trong snapshot **không chứa** thông tin liên kết thực thể.
2. **Chuyển đổi thành bảng liên kết phẳng (`geometry_entity[]`):**
FE so sánh danh sách liên kết hiện tại trong draft với **Baseline** (dữ liệu của commit trước đó) để tính toán cờ hành động (`operation`) cho từng cặp liên kết:
- **`binding` (Tạo mới):** Có trong draft hiện tại nhưng **không có** trong baseline.
- **`reference` (Không đổi):** Có trong cả draft hiện tại và baseline.
- **`delete` (Xóa bỏ):** **Có** trong baseline cũ nhưng **không còn** trong draft hiện tại.
### Giai đoạn 3: Gửi và lưu trữ trên Backend
1. FE đóng gói snapshot này vào trường `snapshot_json` và gửi tới API: `POST /projects/{id}/commits`.
2. Backend nhận được JSON này và lưu trữ nguyên vẹn vào cột `snapshot_json` của bảng `commits`.
*(Lưu ý: Backend hiện tại chưa xử lý cờ `delete` để cập nhật bảng liên kết vật lý `entity_geometries` dưới Database gốc khi duyệt commit, dẫn đến lỗi bất đồng bộ mà bạn đang gặp).*
### Giai đoạn 4: Tải Commit về (Load / Hydrate)
Khi Editor được mở lại hoặc pull commit mới nhất về, FE nhận được `snapshot_json` từ API. Hàm `toEditorSessionSnapshot()``normalizeEditorSnapshot()` sẽ thực hiện ngược lại:
1. **Lọc bỏ dòng đã xóa:**
Duyệt qua mảng `geometry_entity`, nếu gặp dòng có `operation === "delete"`, FE sẽ **bỏ qua ngay lập tức** (không nạp dòng này vào bộ nhớ baseline mới).
2. **Tái hợp nhất (Hydrate) vào GeoJSON:**
Duyệt qua các liên kết hợp lệ còn lại, nhóm chúng theo `geometry_id`, sau đó gán ngược danh sách `entity_ids` vào các Feature GeoJSON tương ứng để Editor có thể vẽ liên kết lên bản đồ.
---
## 3. Bảng tóm tắt trạng thái các cờ `operation`
| Trạng thái liên kết | Draft hiện tại | Baseline trước đó | Cờ `operation` trong Snapshot | FE xử lý khi Load |
| :--- | :---: | :---: | :---: | :--- |
| **Liên kết cũ giữ nguyên** | Có | Có | `reference` | Nạp vào bộ nhớ |
| **Tạo liên kết mới** | Có | Không | `binding` | Nạp vào bộ nhớ |
| **Gỡ liên kết cũ (Unbind)** | Không | Có | `delete` | **Bỏ qua (Không nạp)** |
---
## 4. Tại sao cơ chế này đôi khi gây bối rối?
- **Khác biệt giữa Snapshot JSON và DB gốc:** Snapshot JSON lưu trữ cả lịch sử thay đổi (cờ `delete`), trong khi Database gốc chỉ lưu trạng thái thực tế cuối cùng. Do đó, đọc trực tiếp API snapshot sẽ thấy dòng có cờ `delete`, nhưng chạy code FE hoặc DB đã duyệt thì liên kết đó phải mất đi.
- **Tính một lần (One-time instruction):** Dòng `"delete"` chỉ xuất hiện **đúng 1 lần** trong snapshot của commit thực hiện hành động xóa. Ở commit tiếp theo, do baseline đã lọc bỏ dòng này từ trước, nên diff sẽ không sinh ra dòng `"delete"` đó nữa.
+320
View File
@@ -0,0 +1,320 @@
/**
* Schema tham chiếu cho commit snapshot.
*
* Đây là file doc tự chứa, không import runtime types.
* Mục tiêu là mô tả đúng shape dữ liệu hiện tại của editor/commit/replay
* mà không phụ thuộc trực tiếp vào source code runtime.
*
* Ghi chú:
* - Payload tạo commit hiện là `{ snapshot_json, edit_summary }`.
* - `CommitSnapshot` hiện tương đương `EditorSnapshot`.
* - Nhiều field root để optional vì frontend còn phải đọc snapshot cũ / partial.
* - Replay actions trong dữ liệu thật dùng `params: unknown[]` theo positional tuple.
* - Snapshot replay cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load.
* - Trước khi gửi API, frontend còn normalize thêm một số field, ví dụ
* `time_start/time_end` và `geometries[].type`.
*/
// ---- Root request ----
export type CreateCommitRequest = {
snapshot_json: CommitSnapshot;
edit_summary: string;
};
// ---- GeoJSON / FeatureCollection ----
export type GeometryPreset = "line" | "polygon" | "circle-area" | "point";
export type Geometry =
| ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
| ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
| ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
| ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
export type CircleGeometryMetadata = {
circle_center?: [number, number];
circle_radius?: number;
};
export type FeatureId = string | number;
export type FeatureProperties = {
id: FeatureId;
source?: SnapshotSource;
type?: string | null;
geometry_preset?: GeometryPreset | null;
time_start?: number | null;
time_end?: number | null;
bound_with?: string | null;
// UI/editor-only denormalized fields.
entity_id?: string | null;
entity_ids?: string[];
entity_name?: string | null;
entity_names?: string[];
entity_label_candidates?: Array<{
id: string;
name: string;
time_start?: number | null;
time_end?: number | null;
}>;
entity_type_id?: string | null;
point_label?: string | null;
line_label?: string | null;
polygon_label?: string | null;
};
export type Feature = {
type: "Feature";
properties: FeatureProperties;
geometry: Geometry;
};
export type FeatureCollection = {
type: "FeatureCollection";
features: Feature[];
};
// ---- Snapshot rows ----
export type SnapshotSource = "inline" | "ref";
export type SnapshotOperation = "create" | "update" | "delete" | "reference";
export type EntitySnapshotOperation = SnapshotOperation;
export type GeometrySnapshotOperation = SnapshotOperation;
export type WikiSnapshotOperation = SnapshotOperation;
export type EntitySnapshot = {
id: string;
source: SnapshotSource;
operation?: EntitySnapshotOperation;
name?: string;
description?: string | null;
time_start?: number | null;
time_end?: number | null;
};
export type GeometrySnapshot = {
id: string;
source: SnapshotSource;
operation?: GeometrySnapshotOperation;
type?: string | null;
draw_geometry?: Geometry;
geometry?: Geometry;
bound_with?: string | null;
time_start?: number | null;
time_end?: number | null;
bbox?: {
min_lng: number;
min_lat: number;
max_lng: number;
max_lat: number;
} | null;
};
export type GeometryEntitySnapshot = {
geometry_id: string;
entity_id: string;
operation?: "reference" | "binding" | "delete";
};
export type WikiDoc = string | null;
export type WikiSnapshot = {
id: string;
source: SnapshotSource;
operation?: WikiSnapshotOperation;
title: string;
slug?: string | null;
doc: WikiDoc;
};
export type EntityWikiLinkSnapshot = {
entity_id: string;
wiki_id: string;
operation?: "reference" | "binding" | "delete";
};
// ---- Replay / Scripting System (runtime shape) ----
/**
* Canonical UI action names trong snapshot hiện tại.
* Không còn wrapper `function_name: "UI"` trong shape mới.
*/
export type DialogState = {
text: string; // Subtitle / spoken narrative text
image_url?: string; // Optional image URL
};
export type UIOptionName =
| "timeline"
| "layer_panel"
| "zoom_panel"
| "wiki"
export type MapFunctionName =
| "set_camera_view"
| "set_labels_visible";
export type GeoFunctionName =
| "fly_to_geometries"
| "set_geometry_visibility"
| "follow_geometries_path"
| "hide_others_geometries"
| "pulse_geometry"
| "animate_dashed_border"
| "set_geometry_style"
| "orbit_camera_around_geometry"
| "set_as_background_geometries"
| "remove_from_background_geometries";
export type NarrativeFunctionName =
| "set_dialog";
/**
* Runtime thật hiện dùng positional array cho params.
* File doc này giữ đúng shape đó.
*/
export type ReplayAction<T> = {
function_name: T;
params: unknown[];
};
export type ReplayStep = {
duration: number;
use_UI_function: ReplayAction<UIOptionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_geo_function: ReplayAction<GeoFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
export type ReplayStage = {
id: number;
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
export type BattleReplay = {
id: string;
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
// ---- Replay tuple docs ----
/**
* Doc-only helper để giải thích meaning của từng vị trí trong `params`.
* Runtime không ép các tuple này; chúng chỉ là tài liệu tham chiếu.
*/
export type ReplayCameraViewStateDoc = {
center?: [number, number] | { lng: number; lat: number };
zoom?: number;
pitch?: number;
bearing?: number;
duration?: number;
};
export type ReplayUiParamTupleDocs = {
timeline: [visible: boolean];
layer_panel: [visible: boolean];
zoom_panel: [visible: boolean];
wiki: [wiki_id: string | null];
};
/**
* Snapshot cũ kiểu `function_name: "UI"` chỉ còn là legacy input.
* Frontend hiện normalize chúng sang `function_name: UIOptionName` khi load.
*/
export type ReplayMapFunctionParamTupleDocs = {
set_camera_view: [state: ReplayCameraViewStateDoc];
set_labels_visible: [visible: boolean];
};
export type ReplayGeoFunctionParamTupleDocs = {
fly_to_geometries: [geometry_ids: string[], duration?: number];
set_geometry_visibility: [geometry_ids: string[], visible: boolean];
follow_geometries_path: [
geometry_ids: string[],
duration?: number,
zoom?: number,
pitch?: number,
];
hide_others_geometries: [
geometry_ids: string[],
];
pulse_geometry: [ //beta feature
geometry_id: string,
color?: string,
repeat?: number,
duration?: number,
];
animate_dashed_border: [//beta feature
geometry_id: string,
color?: string,
width?: number,
speed?: number,
duration?: number,
];
set_geometry_style: [//beta feature
geometry_ids: string[],
fill_color?: string,
fill_opacity?: number,
line_color?: string,
line_width?: number,
];
orbit_camera_around_geometry: [//beta feature
geometry_id: string,
zoom?: number,
pitch?: number,
revolutions?: number,
duration?: number,
];
set_as_background_geometries: [
geometry_ids: string[],
];
remove_from_background_geometries: [
geometry_ids: string[],
];
};
export type ReplayNarrativeParamTupleDocs = {
set_dialog: [dialog: DialogState | null];
};
export type ReplayParamTupleDocs =
& ReplayUiParamTupleDocs
& ReplayMapFunctionParamTupleDocs
& ReplayGeoFunctionParamTupleDocs
& ReplayNarrativeParamTupleDocs;
export type ReplayActionTupleDoc<T extends keyof ReplayParamTupleDocs> = {
function_name: T;
params: ReplayParamTupleDocs[T];
};
// ---- Snapshot root ----
export type EditorSnapshot = {
// Legacy snapshots có thể còn field project embedded.
project?: {
id: string;
title: string;
};
editor_feature_collection?: FeatureCollection;
entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[];
geometry_entity?: GeometryEntitySnapshot[];
wikis?: WikiSnapshot[];
entity_wiki?: EntityWikiLinkSnapshot[];
replays?: BattleReplay[];
};
export type CommitSnapshot = EditorSnapshot;
+203
View File
@@ -0,0 +1,203 @@
# UHM Editor - developer guide thực dụng
Tài liệu này dành cho người sửa editor hiện tại, không phải mô tả kiến trúc lý tưởng.
## 1. Entry points quan trọng
- `src/app/editor/[id]/page.tsx`
- orchestration chính của project editor
- `src/uhm/components/Map.tsx`
- container cho map và các hook map
- `src/uhm/lib/editor/state/useEditorState.ts`
- draft geometry + diff + undo
- `src/uhm/lib/editor/state/useEditorSessionState.ts`
- session/UI/project/wiki/entity state
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
- normalize snapshot từ backend và build snapshot gửi ngược lại backend
Nếu chưa đọc 5 file này, chưa nên sửa behavior lớn của editor.
Docs nên đọc trước khi sửa editor:
- `src/uhm/doc/editor_operations.md`
- `src/uhm/doc/editor_data_roles.md`
- `src/uhm/doc/editor_snapshot_contract.md`
- `src/uhm/doc/editor_manual_test_checklist.md`
- `src/uhm/doc/editor_replay_actions.md`
## 2. Cấu trúc thư mục nên ưu tiên hiểu
- `src/uhm/components/editor/`
- panel UI bên trái/phải
- `src/uhm/components/wiki/`
- wiki editor và wiki viewer/sidebar
- `src/uhm/components/map/`
- hooks tích hợp MapLibre
- `src/uhm/lib/map/engines/`
- logic interaction theo mode
- `src/uhm/lib/editor/session/`
- các nhóm session state
- `src/uhm/lib/editor/draft/`
- draft diff và undo
- `src/uhm/lib/editor/snapshot/`
- schema conversion / snapshot semantics
## 3. Cách editor thật sự vận hành
Editor có 3 tầng dữ liệu:
1. `baselineSnapshot`
- snapshot gốc của session
2. `baselineFeatureCollection`
- `FeatureCollection` rehydrate từ snapshot đó
- seed/reset cho `useEditorState()`
3. `mainDraft`
- working copy để user sửa trên map
Map không render trực tiếp `mainDraft` mọi lúc. Page tạo `mapRenderDraft` từ `mainDraft`/`replayDraft`/preview draft sau khi áp timeline/replay filter, rồi truyền xuống `Map` dưới prop `renderDraft`. `labelContextDraft` chỉ dùng để lookup label, không được dùng để quyết định geometry nào hiện trên map.
Khi commit:
- geometry đi từ `mainDraft`
- entity/wiki/link đi từ snapshot collections
- `buildEditorSnapshot()` quyết định operation nào là `reference`, `binding`, `update`, `delete`
Đừng tự build payload ở component nếu chưa hiểu file `editorSnapshot.ts`.
## 4. Khi thêm mode/tool mới
Checklist an toàn:
1. Thêm mode vào `sessionTypes.ts`.
2. Thêm button vào `ToolsPanel.tsx`.
3. Nếu mode cần preview source/layer mới, thêm vào `setupMapLayers()`.
4. Nối mode với engine trong `useMapInteraction.ts`.
5. Nếu tool tạo geometry mới, chọn default:
- `type`
- `geometry_preset`
- `entity_ids`
- `bound_with`
6. Kiểm tra interaction cleanup khi chuyển mode.
Nếu mode chưa được cleanup đúng, map rất dễ giữ preview cũ hoặc event listener cũ.
## 5. Khi thêm geotype mới
Checklist ngắn:
1. Cập nhật `geoTypeMap` nếu cần mapping backend code <-> key.
2. Cập nhật `geometryTypeOptions.ts`.
3. Tạo style file trong `styles/geotypes/`.
4. Register ở `geotypeLayers.ts`.
5. Kiểm tra point icon hoặc label pipeline nếu type mới là point/route/polygon label.
Nếu chỉ sửa `geometryTypeOptions.ts` mà quên style registry, UI sẽ cho chọn type nhưng map không render đúng.
## 6. Khi sửa snapshot semantics
File quan trọng nhất là `editorSnapshot.ts`.
Ở đó đang có hai hướng xử lý khác nhau:
- `normalizeEditorSnapshot(raw)`
- đọc payload từ backend
- rehydrate fields UI như `entity_ids`, `entity_name`, `bound_with`, `time_start`, `time_end`
- `buildEditorSnapshot(options)`
- strip các field generate-only khỏi `editor_feature_collection`
- build `geometry_entity[]``entity_wiki[]`
- tính operation phù hợp
Nguyên tắc:
- feature trong editor có thể mang field denormalized để UI dễ dùng
- payload gửi backend thì không nên mang những field denormalized đó
## 7. Khi sửa wiki editor
Wiki project editor hiện là Quill, không phải Tiptap.
Các file nên đọc trước:
- `WikiSidebarPanel.tsx`
- `PublicWikiSidebar.tsx`
Các điểm dễ làm hỏng:
- sanitize link của Quill
- compatibility với doc dạng HTML/plain text
- slug links nội bộ
- sentinel `__missing__`
Nếu thay storage format, phải sửa cả editor lẫn viewer compatibility path.
## 8. Những key localStorage thật sự đang dùng
- `uhm.backgroundLayerVisibility.v1`
- `uhm:mapProjection`
Hiện không có local draft autosave toàn editor.
Đừng dựa vào doc cũ hoặc giả định rằng F5 sẽ hồi lại draft geometry/wiki/entity.
## 9. Restore commit hiện là FE-only
`CommitHistoryPanel -> Restore`:
- load snapshot từ commit cũ
- reset editor state ở frontend
- không đổi head commit trên backend
Nếu muốn restore server-side thật, cần dùng endpoint backend riêng và sửa cả UI wording.
## 10. Pending submission lock là rule thật
`openSectionEditor()` chủ động chặn project có `PENDING` submission.
Nghĩa là:
- không nên "lách" UI để cho sửa tiếp
- nếu đổi behavior này, phải thống nhất với backend contract
## 11. Performance và state hygiene
Một số nguyên tắc nên giữ:
- dùng `renderDraftRef`/refs trong map engines để tránh rebind handler vô ích
- giữ component panel càng dumb càng tốt, logic patch state đặt ở page/hooks
- khi cần undo cho entity/wiki/link, đi qua `editor.setSnapshot*()` để undo stack biết
- khi cần undo cho replay script, đi qua `editor.mutateActiveReplay()` hoặc replay collection helper hiện có
- hạn chế thêm `JSON.stringify` compare ở chỗ nóng nếu chưa đo hiệu năng
- khi thiết kế các chế độ preview, đảm bảo khôi phục camera view state & projection (Globe/Flat) về trạng thái gốc của editor bằng cách dùng `editorOriginalMapViewStateRef` và calling `restoreEditorOriginalMapState()`.
## 12. Chỗ dễ gây hiểu nhầm khi debug
### Geometry biến mất
Có thể do:
- timeline filter
- geometry visibility theo type
- bound_with filter
Không phải lúc nào cũng là bug render layer.
### Commit count lạ
`Commit (N)``pendingSaveCount`, không phải số mutation backend.
### Selection mất
Selection hiện bám theo `editor.draft`, không theo `mapRenderDraft`. Vì vậy geometry đang chọn có thể bị timeline filter ẩn khỏi map nhưng panel metadata vẫn đọc được draft gốc.
## 13. Nên test gì sau khi sửa
Ít nhất nên test thủ công:
1. mở project có commit cũ
2. tạo geometry mới bằng mode liên quan
3. sửa metadata geometry
4. bind entity và geometry
5. tạo/sửa wiki
6. link entity-wiki
7. commit
8. restore từ commit cũ
9. mở project có pending submission nếu đang debug flow đó
+109
View File
@@ -0,0 +1,109 @@
# Cơ Chế Hút Điểm Khi Vẽ (Snapping Precision)
Tài liệu này giải thích chi tiết về cơ chế hoạt động của tính năng hút điểm (**Snapping**) khi người dùng vẽ hoặc chỉnh sửa các đối tượng địa lý (Point, LineString, Polygon) trên bản đồ lịch sử. Đồng thời so sánh giải thuật cũ (Pixel Space) và giải thuật mới (LngLat Space) để đảm bảo độ chính xác tuyệt đối ở mọi mức Zoom.
---
## 1. Giới thiệu Tính năng Snapping
Khi vẽ biên giới, tuyến đường hành quân hoặc định vị địa điểm, người dùng có thể nhấn giữ phím **`Shift`** hoặc **`Alt`** để tự động hút điểm vẽ hiện tại vào các đối tượng địa lý sẵn có trên bản đồ (như bờ biển, biên giới quốc gia lân cận, hoặc các điểm di tích).
Tính năng này giúp:
- Tránh các khe hở (gaps) hoặc chồng chéo (overlaps) giữa các vùng lãnh thổ giáp ranh.
- Giảm thiểu thời gian và công sức vẽ tay thủ công khi đi theo các đường biên phức tạp.
---
## 2. Vấn đề của giải thuật cũ (Pixel Space)
### Cách thức hoạt động cũ:
1. Chiếu các đỉnh của bản đồ gốc từ Kinh/Vĩ độ (`LngLat`) lên hệ tọa độ màn hình (`Pixel` - $x, y$).
2. Tìm điểm pixel gần nhất trên đoạn thẳng pixel tương ứng với con trỏ chuột.
3. Giải chiếu ngược (`map.unproject`) điểm pixel đó thành tọa độ địa lý `LngLat` để lưu trữ.
### Nhược điểm:
* **Sai số tỉ lệ theo mức Zoom:** Ở mức zoom nhỏ (nhìn từ xa), một đoạn biên giới ngoài đời thực dài hàng trăm kilômét chỉ hiển thị ngắn ngủi vài pixel trên màn hình.
* **Lệch tọa độ khi phóng to:** Sai số làm tròn pixel lúc vẽ ở zoom nhỏ sẽ phóng đại lên thành sai số hàng nghìn mét ngoài thực địa khi người dùng phóng to bản đồ (zoom lớn). Hai quốc gia giáp ranh vẽ ở zoom nhỏ sẽ bị hở hoặc đè lên nhau khi zoom cận cảnh.
---
## 3. Giải thuật mới: Tính toán trên không gian Kinh/Vĩ độ gốc (LngLat Space)
Để khắc phục hoàn toàn hiện tượng lệch tọa độ, giải thuật mới kết hợp cả **Pixel Space** (để lọc tương tác) và **LngLat Space** (để tính toán tọa độ chốt).
```mermaid
graph TD
A[Người dùng di chuột + giữ Shift] --> B(Tìm các đối tượng gần nhất trong bán kính Pixel màn hình)
B --> C(Xác định đoạn thẳng AB mục tiêu trên màn hình)
C --> D{Tính toán tọa độ điểm hút}
D -->|Giải thuật cũ| E(Tính điểm gần nhất trên Pixel màn hình -> Giải chiếu ngược LngLat)
D -->|Giải thuật mới| F(Nội suy tuyến tính LngLat trực tiếp từ tọa độ gốc của A & B)
E --> G(Bị lệch tọa độ khi phóng to bản đồ)
F --> H(Chính xác tuyệt đối 100% ở mọi mức Zoom)
```
### Các bước thực hiện chi tiết:
1. **Bước 1: Lọc đối tượng gần màn hình**
Hệ thống sử dụng khoảng cách pixel để xác định đối tượng mà người dùng đang trỏ tới (ví dụ: nằm trong phạm vi `24px` đến `34px` trên màn hình). Điều này đảm bảo tính năng hoạt động đúng theo cảm quan của mắt người dùng.
2. **Bước 2: Xác định đoạn thẳng mục tiêu**
Hệ thống xác định đoạn thẳng nối hai đỉnh gốc $A(lng_A, lat_A)$ và $B(lng_B, lat_B)$ của đối tượng đích.
3. **Bước 3: Chiếu điểm trực tiếp trên không gian LngLat**
Để tính toán chính xác điểm gần nhất $C(lng_C, lat_C)$ nằm trên đoạn thẳng $AB$, hệ thống thực hiện phép chiếu vector trong không gian tọa độ địa lý địa phương, có bù trừ độ cong kinh tuyến dựa vào vĩ độ trung bình ($\cos(lat)$):
$$\text{lat}_{\text{rad}} = \frac{lat_A + lat_B + lat_{\text{cursor}}}{3} \times \frac{\pi}{180}$$
$$\text{cos}_{\text{lat}} = \cos(\text{lat}_{\text{rad}})$$
Chuyển đổi tạm thời sang hệ tọa độ phẳng cục bộ:
$$x_A = lng_A \times \text{cos}_{\text{lat}}, \quad y_A = lat_A$$
$$x_B = lng_B \times \text{cos}_{\text{lat}}, \quad y_B = lat_B$$
$$x_P = lng_{\text{cursor}} \times \text{cos}_{\text{lat}}, \quad y_P = lat_{\text{cursor}}$$
Tính tham số nội suy $t$ ($0 \le t \le 1$) của điểm hình chiếu trên đoạn thẳng $AB$:
$$dx = x_B - x_A, \quad dy = y_B - y_A$$
$$t = \max\left(0, \min\left(1, \frac{(x_P - x_A)dx + (y_P - y_A)dy}{dx^2 + dy^2}\right)\right)$$
Nội suy tọa độ LngLat chính xác của điểm chốt:
$$lng_C = a[0] + (b[0] - a[0]) \times t$$
$$lat_C = a[1] + (b[1] - a[1]) \times t$$
---
## 4. Ưu điểm vượt trội của Giải thuật mới
* **Độ chính xác tuyệt đối:** Điểm chốt luôn nằm **collinear (thẳng hàng/nội suy tuyến tính)** hoàn hảo giữa hai đỉnh $A$ and $B$ gốc của bản đồ với độ chính xác số thực dấu phẩy động 64-bit.
* **Độc lập với Zoom:** Dù bạn vẽ ở Zoom nhỏ nhất (mức 2 - toàn cầu) hay Zoom lớn nhất (mức 18 - cận cảnh), đường vẽ mới vẫn sẽ khít khịt với đường biên cũ mà không xuất hiện bất kỳ sai số hay khe hở nào.
* **Tối ưu trải nghiệm:** Người dùng có thể bao quát toàn bộ bản đồ quốc gia lớn để vẽ nhanh mà vẫn đạt được độ chuẩn xác tuyệt đối như khi phóng to cận cảnh để chỉnh sửa.
---
## 5. Chỉ báo Màu sắc Hút điểm (Snapping Color Indicators)
Để tăng tính tương tác và giúp người dùng kiểm soát chính xác điểm vẽ đang hút vào đâu, hệ thống tự động đổi màu sắc của **đỉnh đang được kéo** (dragged handle) trong chế độ chỉnh sửa:
| Trạng thái Snap | Màu sắc hiển thị | Mã màu HEX | Ý nghĩa |
| :--- | :---: | :---: | :--- |
| **Hút vào Đỉnh (Vertex)** | Xanh lá | `#22c55e` | Điểm đang kéo trùng khít với một đỉnh mốc cũ của đối tượng địa lý khác. |
| **Hút vào Cạnh (Edge)** | Vàng | `#eab308` | Điểm đang kéo nằm hoàn hảo trên đường nối giữa hai đỉnh của đối tượng địa lý khác. |
| **Không hút (None)** | Xanh dương | `#3b82f6` | Điểm đang kéo tự do, không dính vào bất kỳ đối tượng nào. |
| **Chế độ xóa hàng loạt** | Đỏ | `#ef4444` | Toàn bộ các đỉnh chuyển sang màu đỏ khi bạn bật chế độ xóa đỉnh bằng phím `Delete`. |
---
## 6. Tính Năng Tự Động Bám Biên (Auto-Tracing)
Khi vẽ bản đồ lịch sử, việc copy hoặc chạy dọc theo biên giới có sẵn của quốc gia láng giềng là cực kỳ thường gặp. Thay vì phải click thủ công từng đỉnh, hệ thống hỗ trợ **Auto-Tracing** (Bắt và chạy theo biên giới) với quy trình tối giản và chính xác.
### Cách thức hoạt động:
1. **Bật Snapping:** Nhấn giữ **`Shift`** khi click để đặt điểm bắt đầu trên biên giới (Point 1).
2. **Kích hoạt Trace:** Nhấn tổ hợp **`Shift + T`** và click chọn điểm bắt đầu để hệ thống hiểu bạn muốn bắt đầu một chuỗi trace.
3. **Xem trước (Preview):** Di chuột đến điểm kết thúc mong muốn (Point 2) trên cùng quốc gia đó. Một đường vẽ nháp **màu vàng hổ phách (`#eab308`)** sẽ tự động chạy dọc theo biên giới để bạn xem trước.
4. **Chốt Trace:** Click chuột để chốt. Toàn bộ các đỉnh trung gian sẽ lập tức được chèn vào hình vẽ của bạn. Đường biên giới đã trace xong cũng sẽ giữ nguyên **màu vàng** để phân biệt với phần vẽ tự do (màu xanh lá).
### Thuật toán Dò hướng tối ưu (Area-based Splitting):
Khi chọn 2 điểm trên một đa giác khép kín (Polygon), biên giới sẽ chia đa giác làm 2 con đường (xuôi và ngược chiều kim đồng hồ). Để xác định chính xác người dùng muốn đi đường nào:
1. Hệ thống tạo ra 2 đa giác phụ khép kín tương ứng với 2 con đường bằng cách nối thẳng điểm bắt đầu và điểm kết thúc.
2. Tính diện tích của cả 2 đa giác phụ này.
3. Chọn con đường thuộc đa giác phụ **có diện tích nhỏ hơn** (vì đường biên cần copy luôn là một lát cắt nhỏ của quốc gia, đa giác phụ chứa nó sẽ nhỏ hơn rất nhiều so với phần còn lại của quốc gia láng giềng).
### Quay lại bước trước (Undo/Backspace):
* Khi nhấn **`Backspace`** sau khi thực hiện trace, hệ thống sẽ **xóa hàng loạt** tất cả các đỉnh trung gian được copy của lượt trace đó, đưa hình vẽ quay trở lại ngay điểm bắt đầu trace (Point 1). Điều này giúp người dùng không phải bấm Backspace hàng chục lần để hoàn tác một đường biên phức tạp.
+114
View File
@@ -0,0 +1,114 @@
# UHM Editor - vai trò dữ liệu dễ nhầm
Tài liệu này là glossary ngắn để người sửa code và AI không nhầm các `FeatureCollection`/snapshot gần tên nhau trong editor.
## Luật đọc nhanh
- `mainDraft` là dữ liệu geometry chính để edit và commit.
- `mapRenderDraft` là dữ liệu đã lọc để render map.
- `labelContextDraft` chỉ để lookup label, không quyết định render.
- `baselineFeatureCollection` chỉ để seed/reset session hiện tại.
- `baselineSnapshot` là snapshot gốc để so dirty và build commit delta.
- Các collection `snapshot*` là state hiện tại của snapshot, không phải danh sách delta thô.
## Geometry draft
### `baselineFeatureCollection`
FeatureCollection gốc của phiên editor hiện tại. Nó được tạo từ `baselineSnapshot.editor_feature_collection` khi mở project/restore commit, hoặc từ `EMPTY_FEATURE_COLLECTION` khi project chưa có commit.
Khi field này đổi, `useEditorState()` reset `mainDraft`, rebuild `initialMapRef`, và clear undo stack.
### `mainDraft`
Working copy geometry chính. Đây là nguồn commit cho geometry và là nơi các thao tác create/update/delete/properties ghi vào.
Không dùng `mapRenderDraft` để commit vì `mapRenderDraft` có thể thiếu geometry do timeline/replay/preview filter.
### `editor.draft`
Draft active theo mode:
- mode thường: `editor.draft === mainDraft`
- mode `replay`: `editor.draft === replayDraft`
Panel metadata và selection dùng `editor.draft` để vẫn đọc được geometry ngay cả khi map filter đang ẩn geometry đó.
### `replayDraft`
FeatureCollection local hydrate từ `mainDraft` theo `activeReplayDraft.target_geometry_ids`. Nó chỉ phục vụ replay edit mode, không thay thế `mainDraft`.
### `mapRenderDraft`
FeatureCollection do page tạo ra để truyền vào `Map` prop `renderDraft`.
Nguồn có thể là:
- `editor.mainDraft` ở mode thường
- `editor.replayDraft` ở replay edit mode
- `previewSession.draft` đã áp hidden ids ở replay preview mode
Sau đó page có thể áp timeline filter. Đây là nguồn duy nhất quyết định geometry nào xuất hiện trên map.
### `renderDraft`
Tên prop trong `Map.tsx`/`useMapSync.ts`. Đây là `mapRenderDraft` sau khi truyền xuống component map.
### `renderDraftRef`
Ref của `renderDraft` trong map interaction. Ref này dùng cho hover/select/edit trên các geometry đang render/interact. Không nhầm với `draftRef` nội bộ trong `useEditorState()`.
## Label context
### `labelContextBaseDraft`
FeatureCollection gốc để build label context. Nó có thể là draft rộng hơn `mapRenderDraft` để label vẫn resolve được entity/geometry liên quan.
### `mapLabelContextDraft`
FeatureCollection đã enrich label/entity name từ `labelContextBaseDraft`.
Rule quan trọng: `mapLabelContextDraft` chỉ dùng cho label lookup. Nó có thể chứa geometry bị timeline filter ẩn, nên không được dùng để quyết định render source hoặc geometry visibility.
## Snapshot state
### `baselineSnapshot`
Snapshot gốc của session hiện tại. Dùng để so dirty và để `buildEditorSnapshot()` biết row nào là reference/binding/update/delete.
### `snapshotEntityRows`
Các entity row của snapshot hiện tại. Đây là rows cho payload `entities[]`, không phải entity catalog toàn hệ thống.
### `snapshotWikis`
Các wiki row của snapshot hiện tại. Đây là source truth cho wiki trong commit.
### `snapshotEntityWikiLinks`
Các link entity-wiki hiện tại của snapshot. Snapshot builder sẽ tự sinh operation phù hợp so với `baselineSnapshot.entity_wiki`.
## Binding và visibility
### `geometry_entity[]`
Join table persist quan hệ geometry-entity trong snapshot commit. `feature.properties.entity_ids` chỉ là field denormalized cho UI.
### `bound_with`
Field geometry-geometry trên feature con, lưu id geometry cha mà nó nằm trong. `bound_with` không tính là entity binding; geometry không có `entity_ids/entity_id` hợp lệ vẫn là orphan.
### `geometryVisibility`
Map local visibility override. Key có thể là geometry id hoặc semantic geo type key. Đây là UI-only, không đi snapshot.
### `applyGeometryBindingFilter`
Filter map theo selection/bound_with. Chỉ ảnh hưởng render trên map, không đổi draft và không đi snapshot.
## Guard rails
- Render path: `mapRenderDraft -> Map.renderDraft -> useMapSync(renderDraft) -> MapLibre sources`.
- Label path: `labelContextBaseDraft -> mapLabelContextDraft -> useMapSync(labelContextDraft)`.
- Commit path: `mainDraft + snapshotEntityRows + snapshotWikis + snapshotEntityWikiLinks + effectiveReplays -> buildEditorSnapshot()`.
- Orphan validation vẫn chạy trên `mainDraft`, không phụ thuộc map filter.
+333
View File
@@ -0,0 +1,333 @@
# UHM Editor - tính năng hiện có
Tài liệu này mô tả editor đang chạy tại `src/app/editor/[id]/page.tsx` và các panel liên quan trong `src/uhm/components/`.
Mục tiêu của tài liệu là phản ánh đúng implementation hiện tại, không mô tả các tính năng chưa được nối dây.
Docs liên quan:
- `src/uhm/doc/editor_operations.md`: ma trận thao tác/undo/snapshot.
- `src/uhm/doc/editor_snapshot_contract.md`: contract commit snapshot.
- `src/uhm/doc/editor_manual_test_checklist.md`: checklist test tay.
- `src/uhm/doc/editor_replay_actions.md`: catalog action replay.
## 1. Cách mở editor
- `GET /editor/[id]`: mở editor đầy đủ với map, panel trái và panel phải.
## 2. Bố cục giao diện
- Cột trái (`Editor.tsx`)
- `ProjectPanel`
- `ToolsPanel`
- `CommitPanel`
- `CommitHistoryPanel`
- `UndoListPanel`
- Khu vực giữa
- `Map`
- `TimelineBar` khi không ở `replay`; trong `replay_preview` phụ thuộc action `timeline`
- Cột phải (`BackgroundLayersPanel`)
- Search hợp nhất
- Geometry Binding
- Entities
- Wiki
- Entity ↔ Wiki
- Selected Geometry
Hai cột hai bên đều resize được bằng drag handle.
## 3. Editor modes
`EditorMode` hiện có:
- `idle`
- `select`
- `draw`
- `add-point`
- `add-line`
- `add-path`
- `add-circle`
- `replay`
- `replay_preview`
Ý nghĩa thực tế:
- `select`: chọn geometry, xóa geometry, mở vertex editing cho polygon/circle, vào replay.
- `draw`: vẽ polygon.
- `add-point`: tạo point.
- `add-line`: vẽ `LineString`.
- `add-path`: vẽ `LineString` có render arrow layer cho route.
- `add-circle`: kéo chuột để tạo polygon hình tròn, có `circle_center``circle_radius`.
- `replay`: chế độ tập trung vào một geometry và tập `target_geometry_ids`, có sidebar sửa stage/step/action, preview overlay và undo riêng cho session replay.
- `replay_preview`: chạy preview từ replay đang edit; action điều khiển camera/timeline/wiki/narrative overlay và hidden geometry ids.
## 4. Công cụ vẽ và phím điều khiển
### Polygon (`draw`)
- Click để thêm đỉnh.
- `Shift` hoặc `Alt` khi click/move để snap vào geometry gần nhất.
- `Enter` để hoàn tất polygon.
- `Escape` để hủy.
- `Backspace` để bỏ đỉnh cuối.
Geometry mới mặc định có:
- `type: "country"`
- `geometry_preset: "polygon"`
- `entity_ids: []`
- `bound_with: null`
### Point (`add-point`)
- Click một lần để tạo point.
- Geometry mới mặc định có `type: "city"``geometry_preset: "point"`.
### Line (`add-line`)
- Click để thêm đỉnh.
- `Enter` để hoàn tất.
- `Escape` để hủy.
- `Backspace` để bỏ đỉnh cuối.
Geometry mới mặc định có `type: "defense_line"``geometry_preset: "line"`.
### Path (`add-path`)
- Tương tự `add-line`, nhưng render preview và layer theo route/path.
- Geometry mới mặc định có `type: "attack_route"``geometry_preset: "line"`.
### Circle (`add-circle`)
- `mousedown` để đặt tâm.
- Kéo chuột để thay đổi bán kính.
- `mouseup` để hoàn tất.
- `Escape` để hủy.
Geometry trả về vẫn là `Polygon`, nhưng có thêm:
- `circle_center`
- `circle_radius`
Mặc định `type: "war"``geometry_preset: "circle-area"`.
## 5. Chọn và sửa geometry
### Selection
- `Map` trả về danh sách `selectedFeatureIds`.
- `SelectedGeometryPanel`, `ProjectEntityRefsPanel``GeometryBindingPanel` đều đọc từ selection này.
- Multi-select có tồn tại ở level state, nhưng một số thao tác chỉ hợp lệ khi các geometry cùng shape.
### Vertex editing (Chỉnh sửa đỉnh)
Khi đang ở chế độ `select`, nhấp đúp vào geometry để mở chế độ chỉnh sửa chi tiết qua `editingEngine`:
* **Kéo thả đỉnh (Move Vertex):** Kéo các handle (điểm tròn) để dịch chuyển vị trí đỉnh.
* **Chỉnh sửa hình tròn (Circle Editing):**
* Handle `0`: di chuyển tâm hình tròn.
* Handle `1`: thay đổi bán kính.
* **Thêm đỉnh mới:** `Ctrl` (hoặc `Cmd`) + click vào một cạnh bất kỳ của Polygon/LineString để chèn thêm đỉnh mới.
* **Vẽ tiếp / Bám dọc biên (Continue Draw / Tracing):**
* Nhấp chuột phải vào một đỉnh và chọn `"Vẽ tiếp về bên trái"` hoặc `"Vẽ tiếp về bên phải"`.
* Trong quá trình vẽ tiếp, nhấn giữ phím `T` để tự động bám dọc (trace) theo biên của hình học khác gần nhất.
* Hệ thống tự khóa snap vào đối tượng đang bám để tránh đứt gãy hình học, tự động khâu nối (`stitchRing`) và làm sạch đỉnh trùng bằng sai số sai biệt $10^{-9}$ để giữ nguyên nút kết nối.
* Nhấn `Backspace` để hoàn tác (undo) các đỉnh hoặc toàn bộ đoạn vừa bám (trace).
* Nhấn `Enter` để lưu đoạn vẽ tiếp, hoặc `Escape` để hủy.
* **Xóa hàng loạt đỉnh (Range Delete):**
* Nhấn phím `Delete` (hoặc click nút Xóa đỉnh trên panel) để vào chế độ Xóa đỉnh (các handle đổi sang màu **Đỏ**).
* *Xóa đơn:* Nhấp chuột trái vào bất kỳ đỉnh nào để xóa đỉnh đó.
* *Xóa khoảng (Range Delete):*
* Giữ phím `Shift` và click vào đỉnh đầu tiên (đổi sang màu **Xanh lá** làm điểm neo, các đỉnh khác đổi sang màu **Xanh dương** an toàn).
* Di chuyển chuột tới đỉnh thứ hai: Toàn bộ cung đường đi giữa hai điểm neo dự kiến xóa sẽ hiển thị màu **Đỏ**, các đỉnh không bị ảnh hưởng sẽ giữ màu **Xanh dương**.
* Đối với Polygon, mặc định cung đường ngắn nhất (trung điểm gần chuột nhất) sẽ được chọn. Người dùng có thể **nhấn giữ phím Alt** để cưỡng bức chọn cung ngược lại.
* Click vào đỉnh thứ hai (hoặc nhấn giữ Shift + click) để xác nhận xóa toàn bộ các đỉnh màu đỏ ở giữa.
* Nhấn `Escape` hoặc click chuột phải, hoặc click ra vùng trống ngoài bản đồ để hủy chọn khoảng xóa.
* Nhấn `Delete` lần nữa hoặc nhấn `Escape` (khi không chọn khoảng) để thoát chế độ Xóa đỉnh.
* **Áp dụng & Hủy chỉnh sửa:**
* Nhấn `Enter` để lưu toàn bộ thay đổi hình học.
* Nhấn `Escape` (khi không trong chế độ xóa/vẽ tiếp) để hủy bỏ mọi thay đổi và quay lại trạng thái cũ.
### Xóa geometry
- Hành động xóa toàn bộ một hình học được đi qua `onDeleteFeature`.
- Undo có thể khôi phục lại geometry vừa xóa cùng các liên kết tương ứng.
## 6. Metadata geometry
`SelectedGeometryPanel` hiện cho phép sửa:
- `type_key`
- `time_start`
- `time_end`
`bound_with` không nằm trong form metadata; việc bind/unbind geometry hiện đi qua `GeometryBindingPanel`.
Các ràng buộc đang có:
- `time_start``time_end` phải parse được thành số hoặc để trống.
- Nếu cả hai đều có giá trị thì `time_start <= time_end`.
Khi apply, editor patch trực tiếp `feature.properties` của geometry đang chọn.
## 7. Timeline
`TimelineBar` hiện dùng dải năm cố định từ util timeline.
- Slider + numeric input cùng điều khiển `timelineDraftYear`.
- Có toggle `filterEnabled`.
- Khi bật filter:
- mọi geometry chỉ hiện nếu năm hiện tại nằm trong `[time_start, time_end]`
- geometry mới tạo trong session cũng tuân theo filter này
Timeline hiện là filter phía client, không fetch lại dữ liệu project theo năm.
## 8. Search hợp nhất và import
Panel phải có `UnifiedSearchBar` với 3 loại search:
- `entity`
- tìm local + backend theo tên/mô tả
- nút `Add` sẽ thêm entity vào `snapshotEntityRows` dưới dạng `reference`
- `wiki`
- tìm backend theo title
- nút `Add` sẽ thêm wiki vào `snapshotWikis` dưới dạng `reference`
- `geo`
- tìm geometry theo tên entity
- nút `Import` sẽ import geometry vào draft hiện tại
- đồng thời thêm entity tương ứng vào `snapshotEntityRows` nếu chưa có
- import giữ nguyên timeline filter; geometry mới import có thể bị ẩn nếu ngoài năm hiện tại
## 9. Entity và binding
### Project entities
`ProjectEntityRefsPanel` hỗ trợ:
- tạo entity local (`source: "inline"`, `operation: "create"`)
- sửa entity đã có trong snapshot
- bind/unbind entity vào geometry đang chọn
Editor không gọi API create entity riêng ở bước này. Entity mới chỉ sống trong snapshot cho tới khi commit project.
### Geometry ↔ Entity
Liên kết nhiều-nhiều được thể hiện bằng:
- field UI trên feature: `entity_id`, `entity_ids`, `entity_name`, `entity_names`
- payload snapshot: `geometry_entity[]`
Panel `ProjectEntityRefsPanel` là nơi bind/unbind entity theo geometry đang chọn.
### Geometry ↔ Geometry
`GeometryBindingPanel` thao tác trên `feature.properties.bound_with` của geometry con.
- Chọn một geometry làm gốc.
- Bind/unbind với geometry khác trong project bằng cách set/clear `bound_with` của geometry con.
- Có nút focus để zoom vào geometry trong list binding.
- Có toggle `Filter`: map chỉ hiển thị geometry liên quan tới selection nếu filter bound_with đang bật.
- Row geometry hiển thị chip trạng thái trong panel:
- `no entity` nếu geometry chưa bind entity.
- `no time` nếu thiếu cả `time_start``time_end`.
- `partial time` nếu chỉ có một trong hai mốc thời gian.
- `timeline` hoặc `out timeline` khi timeline filter đang bật.
- `hidden`, `bound`, `new` theo trạng thái UI tương ứng.
- ID geometry không render trực tiếp trong row; ID chỉ nằm trong `title` tooltip của row/nút thao tác.
- Geometry mồ côi không có style riêng trên map. Cảnh báo nằm ở panel và validation commit/submit.
## 10. Wiki và entity-wiki
### Wiki panel
`WikiSidebarPanel` dùng `react-quill-new`.
Các khả năng đang có:
- tạo wiki local
- sửa title/slug/doc
- import HTML file
- export nội dung hiện tại theo định dạng suy ra từ `doc`
- lưu wiki vào `snapshotWikis`
Storage thực tế của `doc`:
- format mới: HTML string
- plaintext fallback
### Internal wiki link
Toolbar `link` mở modal custom:
- tìm wiki local theo title/slug
- tìm wiki global từ server
- chèn link bằng `slug`, không bắt buộc scheme URL
- có thể tạo `__missing__` link để đánh dấu liên kết chưa map được
### Entity ↔ Wiki
`EntityWikiBindingsPanel` quản lý `snapshotEntityWikiLinks`.
- link mới dùng `operation: "binding"`
- unlink bằng cách remove row khỏi editor state
- khi build snapshot, editor tự sinh delta `binding` hoặc `delete` so với baseline
## 11. Commit, submit và restore
### Pending change count
Số trong nút `Commit` không chỉ là geometry diff. Nó gồm:
- `editor.changeCount`
- `+1` nếu danh sách wiki dirty
- `+1` nếu danh sách entity dirty
- `+1` nếu danh sách entity-wiki dirty
- `+1` nếu replay script dirty
### Commit
`commitSection()`:
- build snapshot từ `mainDraft` + `snapshotEntityRows` + `snapshotWikis` + `snapshotEntityWikiLinks` + `effectiveReplays`
- chặn commit nếu không có thay đổi, còn orphan geometry, hoặc payload vượt guardrail kích thước
- gửi `snapshot_json` lên API tạo commit
- nếu thành công:
- reset baseline sang snapshot vừa commit
- clear undo stack
- clear geometry changes
### Submit
- chỉ submit được khi project có `head_commit_id`
- không submit nếu còn thay đổi chưa commit
- không submit nếu còn orphan geometry
### Restore
`CommitHistoryPanel` có nút `Restore`, nhưng restore hiện là:
- chỉ chạy khi không còn pending changes
- load snapshot từ commit cũ vào FE
- không đổi head commit trên backend
Đây là FE-only restore để tiếp tục chỉnh sửa từ snapshot cũ.
## 12. Pending submission lock
Khi `openSectionEditor()` thấy project có submission `PENDING`, editor bị chặn mở.
UI hiện tại:
- hiển thị màn hình lock
- cho phép xóa pending submission để unlock
Luồng này bám sát rule backend mới, không phải readonly mode giả lập ở FE.
## 13. Những thứ doc cũ từng nhắc nhưng code hiện chưa có
Các mục sau không nên xem là tính năng hiện hành của editor:
- autosave toàn bộ draft editor vào `localStorage`
- restore head commit trên backend từ UI editor
- import/export wiki JSON chuyên biệt như một workflow riêng
- bộ shortcut toàn cục kiểu `Ctrl+S`, `Ctrl+Z`, `Ctrl+Y`
- workflow duyệt `Approved/Rejected` được render đầy đủ trong editor page
+131
View File
@@ -0,0 +1,131 @@
# UHM Editor - manual test checklist
Cập nhật: 2026-05-22.
Checklist này dùng sau mỗi lần sửa editor. Không thay thế typecheck/lint, nhưng bắt các lỗi workflow mà static check khó thấy.
## 1. Preflight
- Mở `/editor/[id]` với một project có ít nhất một geometry/entity/wiki.
- Mở console browser, đảm bảo không có runtime error ngay khi load.
- Kiểm tra map render đủ geometry, panel trái/phải không overlap.
- Kiểm tra `UndoListPanel` ban đầu không có action lạ từ lần load.
## 2. Geometry create/edit/delete
| Bước | Thao tác | Kỳ vọng |
| --- | --- | --- |
| 1 | Vẽ polygon ở `draw` mode | Geometry mới được select, panel hiện `no entity``no time` |
| 2 | Undo | Polygon biến mất, undo stack giảm |
| 3 | Tạo point | Point render bằng icon geotype bình thường, không đổi màu riêng vì orphan |
| 4 | Apply type/time cho point | Panel đổi `no time`/`partial time` đúng theo input |
| 5 | Sửa vertex/circle nếu có geometry phù hợp | Undo khôi phục geometry cũ |
| 6 | Xóa một geometry | Geometry biến mất, undo khôi phục đúng vị trí trong list |
| 7 | Multi-select cùng shape và xóa | Undo khôi phục toàn bộ geometry đã xóa |
## 3. Geometry status panel
- Row không hiển thị ID trực tiếp.
- Hover row thấy tooltip có `ID: ...`.
- Geometry không entity hiện chip `no entity`.
- Geometry thiếu cả `time_start/time_end` hiện `no time`.
- Geometry thiếu một trong hai field time hiện `partial time`.
- Bật timeline filter:
- Geometry còn visible hiện chip `timeline`.
- Geometry bị lọc khỏi draft visible hiện chip `out timeline`.
- Eye button set `hidden`, map ẩn geometry và panel hiện chip `hidden`.
- `NewBadge` vẫn hiện cho geometry mới/import chưa persisted.
## 4. Entity và geometry-entity
| Bước | Thao tác | Kỳ vọng |
| --- | --- | --- |
| 1 | Search entity và Add vào project | Entity xuất hiện trong panel, undo gỡ entity ref |
| 2 | Tạo entity local | Entity mới xuất hiện, form reset, undo gỡ entity |
| 3 | Sửa entity name/time | Undo khôi phục metadata entity |
| 4 | Bind entity vào selected geometry | Chip `no entity` biến mất, undo trả lại trạng thái cũ |
| 5 | Unbind entity | Chip `no entity` hiện lại, commit bị chặn nếu geometry còn orphan |
| 6 | Multi-select khác shape rồi bind entity | UI báo không thể bind nhiều geometry khác loại |
## 5. Geometry-geometry binding
- Chọn một geometry, bind geometry khác trong `GeometryBindingPanel`.
- Panel hiện chip `bound` cho geometry liên quan.
- Toggle Filter: map chỉ hiện selection, selected children và parent/root phù hợp.
- Undo bind/unbind geometry phải khôi phục `properties.bound_with`.
- Bind geometry-geometry không làm mất chip `no entity` nếu geometry vẫn chưa bind entity.
## 6. Wiki và entity-wiki
| Bước | Thao tác | Kỳ vọng |
| --- | --- | --- |
| 1 | Search wiki và Add | Wiki ref xuất hiện, undo gỡ wiki ref |
| 2 | Tạo/sửa wiki local | Undo khôi phục danh sách/wiki content |
| 3 | Bind entity-wiki | Link xuất hiện, undo khôi phục links |
| 4 | Xóa wiki đang có entity-wiki links | Wiki và links liên quan bị xóa cùng lúc |
| 5 | Undo xóa wiki | Wiki và entity-wiki links cùng trở lại |
| 6 | Insert wiki link trong editor | Link nằm trong doc sau khi lưu wiki |
## 7. Replay
- Chọn geometry có entity, bấm replay.
- Replay mở với MAIN geo và các target ids có `bound_with` trỏ tới MAIN.
- Tạo stage, tạo step, đổi duration.
- Thêm narrative action `set_title``set_descriptions`.
- Thêm map action `set_time_filter`, `show_labels`, `hide_labels`.
- Thêm geo action `fly_to_geometries`, `hide_geometries`, `show_geometries`.
- Undo trong replay mode chỉ undo replay session, không undo main geometry.
- Play preview:
- Step selection chạy đúng thứ tự.
- Stop/reset khôi phục title/dialog/image/hidden geometry/timeline/map camera cơ bản và projection (Globe/Flat) ban đầu.
- Thoát replay rồi vào lại, detail vẫn còn nếu chưa undo.
## 8. Import GEO từ search
- Search GEO theo entity.
- Import một geometry chưa có trong draft.
- Kỳ vọng:
- Timeline filter tự tắt.
- Geometry được select.
- Entity ref được thêm nếu chưa có.
- Undo gỡ cả geometry và entity ref nếu entity ref được tạo trong cùng action.
- Import lại cùng GEO:
- Không tạo duplicate geometry.
- Chỉ select geometry đã có.
## 9. Commit và restore
| Bước | Thao tác | Kỳ vọng |
| --- | --- | --- |
| 1 | Commit khi không có thay đổi | Báo không có thay đổi |
| 2 | Commit khi còn orphan geometry | Bị chặn, select orphan đầu tiên, panel entity báo chưa bind |
| 3 | Bind entity rồi commit | Commit thành công, undo stack cleared, pending count về 0 |
| 4 | Kiểm snapshot commit | Có `geometries`, `geometry_entity`, `entities`, `wikis`, `entity_wiki`, `replays` đúng thay đổi |
| 5 | Restore commit cũ | Draft/snapshot panels reset theo commit |
## 10. Submit
- Khi còn pending changes, submit phải bị chặn và yêu cầu commit trước.
- Khi còn orphan geometry, submit bị chặn giống commit.
- Khi đã commit sạch và không orphan, submit tạo submission id/status.
- Nếu project bị pending submission lock, banner unlock hoạt động và mở lại project.
## 11. UI-only checks
Các thao tác sau không được thêm undo action và không làm tăng pending save count:
- Đổi timeline year/filter.
- Toggle background layers.
- Hide/show geometry local.
- Focus geometry từ panel.
- Resize panel.
- Search query.
- Pick/paste/remove image overlay trace.
- Replay preview play/stop/reset (khôi phục hoàn toàn camera view state và projection của editor ban đầu).
## 12. Final smoke
- `npx tsc --noEmit --pretty false`.
- Targeted eslint cho file vừa sửa.
- `git diff --check`.
- Nếu sửa frontend UI lớn: mở dev server và test ít nhất desktop viewport.
+136
View File
@@ -0,0 +1,136 @@
# UHM Editor - Tài liệu tham chiếu thuật toán Toán học & Hình học
Tài liệu này hệ thống hóa toàn bộ các công thức toán học, thuật toán hình học không gian (Geospatial) và thuật toán đồ thị được áp dụng trong công cụ chỉnh sửa bản đồ của **Ultimate History Map (UHM)**.
---
## 1. Công thức khoảng cách Haversine (`distanceMeters`)
Để tính toán khoảng cách thực tế giữa hai tọa độ Địa lý $(lng_1, lat_1)$ và $(lng_2, lat_2)$ trên bề mặt cong của Trái Đất (mô hình cầu), hệ thống sử dụng công thức Haversine.
### Công thức toán học
Cho bán kính trung bình của Trái Đất $R = 6,378,137\text{ m}$. Chuyển đổi tọa độ từ độ (degrees) sang radian (radians):
$$\Delta lat = (lat_2 - lat_1) \times \frac{\pi}{180}$$
$$\Delta lng = (lng_2 - lng_1) \times \frac{\pi}{180}$$
Đại lượng trung gian $a$:
$$a = \sin^2\left(\frac{\Delta lat}{2}\right) + \cos(lat_1 \times \frac{\pi}{180}) \times \cos(lat_2 \times \frac{\pi}{180}) \times \sin^2\left(\frac{\Delta lng}{2}\right)$$
Khoảng cách góc $c$:
$$c = 2 \times \operatorname{atan2}\left(\sqrt{a}, \sqrt{1 - a}\right)$$
Khoảng cách thực tế $d$ (mét):
$$d = R \times c$$
---
## 2. Chiếu điểm lên đoạn thẳng & Snap hình học (`snapToNearestGeometry`)
Khi di chuyển hoặc kéo đỉnh, hệ thống chiếu tọa độ chuột hiện tại lên các cạnh của đa giác hoặc đường thẳng để tìm điểm bám (snap) gần nhất.
### Chiếu Vector tuyến tính
Xét một đoạn thẳng nối từ điểm $A(x_A, y_A)$ đến điểm $B(x_B, y_B)$ và điểm chuột hiện tại là $P(x_P, y_P)$.
Ta định nghĩa các vector:
$$\vec{AB} = B - A = (x_B - x_A, y_B - y_A)$$
$$\vec{AP} = P - A = (x_P - x_A, y_P - y_A)$$
Hình chiếu vuông góc của $P$ lên đường thẳng chứa $AB$ được xác định bởi tham số tỉ lệ $t$:
$$t = \frac{\vec{AP} \cdot \vec{AB}}{\|\vec{AB}\|^2} = \frac{(x_P - x_A)(x_B - x_A) + (y_P - y_A)(y_B - y_A)}{(x_B - x_A)^2 + (y_B - y_A)^2}$$
Để giới hạn điểm chiếu nằm trực tiếp **trong lòng đoạn thẳng** $AB$, ta ràng buộc tham số $t$ về đoạn $[0, 1]$:
$$t_{\text{clamped}} = \max(0, \min(1, t))$$
Tọa độ điểm chiếu gần nhất $P_{\text{projected}}$:
$$P_{\text{projected}} = A + t_{\text{clamped}} \times \vec{AB}$$
### Ngưỡng Snap (Tolerance)
Hệ thống chuyển đổi khoảng cách từ điểm chiếu đến con trỏ chuột sang đơn vị pixel màn hình. Nếu khoảng cách hình chiếu nhỏ hơn ngưỡng sai số cho phép (ví dụ: $8\text{px}$ hoặc $1\text{m}$ thực tế), con trỏ sẽ tự động bị hút vào điểm $P_{\text{projected}}$ đó.
---
## 3. Tạo hình tròn đa giác (`buildCircleRing`)
Vì các chuẩn dữ liệu GeoJSON không hỗ trợ kiểu dữ liệu `Circle` nguyên bản, hệ thống chuyển đổi hình tròn có tâm $C(lng_C, lat_C)$ và bán kính $r$ (mét) thành một đa giác khép kín (`Polygon`) gồm 64 đỉnh.
### Công thức lượng giác trên mặt cầu
Với mỗi góc $\theta$ chạy từ $0^{\circ}$ đến $360^{\circ}$ (chia thành 64 phân đoạn, mỗi bước $\Delta\theta = \frac{2\pi}{64}$ radians):
1. Tính bán kính góc $d = \frac{r}{R}$ (với $R$ là bán kính Trái Đất).
2. Tọa độ vĩ độ mới ($lat_{\theta}$):
$$lat_{\theta} = \arcsin\left(\sin(lat_C) \cos(d) + \cos(lat_C) \sin(d) \cos(\theta)\right)$$
3. Tọa độ kinh độ mới ($lng_{\theta}$):
$$lng_{\theta} = lng_C + \operatorname{atan2}\left(\sin(\theta) \sin(d) \cos(lat_C), \cos(d) - \sin(lat_C) \sin(lat_{\theta})\right)$$
Tập hợp 64 tọa độ $(lng_{\theta}, lat_{\theta})$ tạo thành vòng khép kín mô tả chính xác biên hình tròn trên bản đồ.
---
## 4. Kiểm tra vòng khép kín sai số cao (`isClosed`)
Trong tính toán đồ thị địa lý, do sai số dấu phẩy động (floating-point precision) tích lũy trong quá trình tính toán của trình duyệt, tọa độ điểm đầu và điểm cuối của Polygon có thể lệch nhau một lượng cực nhỏ.
Hệ thống áp dụng sai số tuyệt đối $\epsilon = 10^{-9}$ để kiểm tra tính khép kín:
$$\Delta lng = |lng_{\text{start}} - lng_{\text{end}}|$$
$$\Delta lat = |lat_{\text{start}} - lat_{\text{end}}|$$
$$\text{isClosed} = (\Delta lng < 10^{-9}) \land (\Delta lat < 10^{-9})$$
Điều này ngăn chặn việc hệ thống phân loại nhầm Polygon khép kín thành LineString hở.
---
## 5. Khâu nối và làm sạch đường biên (`stitchRing` & `cleanRing`)
Khi bám dọc biên (Trace) từ một đỉnh vẽ tiếp, hệ thống tiến hành cắt và ghép 3 mảng tọa độ:
1. `prefix`: Các điểm trước điểm bắt đầu trace.
2. `activeDrawn`: Các điểm thu được từ đường đi trace.
3. `suffix`: Các điểm sau điểm kết thúc trace.
Do quá trình ghép nối trực tiếp tại các ranh giới khâu (join points) dễ sinh ra các điểm trùng lặp gần nhau (sai số nhỏ), hàm `cleanRing` sẽ duyệt qua mảng kết quả và loại bỏ các điểm trùng kế tiếp nếu khoảng cách giữa chúng bé hơn $\epsilon = 10^{-9}$:
$$\text{duplicate} = (|lng_i - lng_{i-1}| < 10^{-9}) \land (|lat_i - lat_{i-1}| < 10^{-9})$$
---
## 6. Định hướng Đông - Tây / Trái - Phải (`isToTheRight`)
Để xác định một đỉnh nằm bên trái hay bên phải đỉnh khác khi vẽ tiếp mà không phụ thuộc vào thứ tự chỉ mục ban đầu (vốn không trực quan cho người dùng):
$$\text{isToTheRight}(A, B) = \begin{cases}
lng_A > lng_B, & \text{nếu } lng_A \neq lng_B \\
lat_A < lat_B, & \text{nếu } lng_A = lng_B
\end{cases}$$
Quy ước này giúp người dùng dễ dàng định hình hướng đi (bên phải tương đương với đi về phía Đông hoặc đi xuống phía Nam nếu trùng kinh độ).
---
## 7. Giải thuật chọn cung xóa của Polygon trong Range Delete
Khi xóa một khoảng đỉnh trên đa giác khép kín giữa 2 đỉnh chỉ mục $i_{\text{start}}$ và $i_{\text{hover}}$, đa giác luôn bị chia làm hai cung đường đi thay thế:
* **Đường đi A (Thuận chiều kim đồng hồ):**
$$P_A = \{ (i_{\text{start}} + 1) \bmod N, \dots, i_{\text{hover}} - 1 \bmod N \}$$
* **Đường đi B (Ngược chiều kim đồng hồ):**
$$P_B = \{ (i_{\text{start}} - 1 + N) \bmod N, \dots, i_{\text{hover}} + 1 \bmod N \}$$
### Khoảng cách hình chiếu Pixel (Smart Decision)
Để tự động chọn cung đường người dùng muốn xóa:
1. Xác định tọa độ trung điểm hình học của từng cung đường đi.
* Nếu cung đường trống (xóa trực tiếp giữa 2 đỉnh kề nhau), trung điểm là trung điểm của đoạn thẳng nối 2 đỉnh neo:
$$M = \left(\frac{lng_{\text{start}} + lng_{\text{hover}}}{2}, \frac{lat_{\text{start}} + lat_{\text{hover}}}{2}\right)$$
* Nếu cung có chứa các đỉnh trung gian, lấy tọa độ của đỉnh nằm chính giữa mảng chỉ mục đó.
2. Chiếu tọa độ trung điểm của $P_A$ và $P_B$ lên hệ tọa độ pixel của màn hình thiết bị thông qua phép chiếu MapLibre (`map.project`):
$$M_{\text{pixel}, A} = \text{project}(M_A)$$
$$M_{\text{pixel}, B} = \text{project}(M_B)$$
3. Đo khoảng cách Euclid từ vị trí con trỏ chuột hiện tại $Cursor(x, y)$ đến hai hình chiếu trung điểm:
$$d_A = \sqrt{(x - x_{M, A})^2 + (y - y_{M, A})^2}$$
$$d_B = \sqrt{(x - x_{M, B})^2 + (y - y_{M, B})^2}$$
Cung đường nào có khoảng cách ngắn hơn ($d \le$ đối thủ) sẽ tự động được bôi đỏ để chuẩn bị xóa.
### Ghi đè bằng phím Alt (Alt Key Override)
Nếu người dùng nhấn giữ phím **Alt**, hệ thống lập tức phủ quyết kết quả so sánh khoảng cách và chọn cung đường ngược lại:
$$\text{DeleteRange} = \begin{cases}
P_B, & \text{nếu } (d_A \le d_B \land \text{AltPressed}) \lor (d_A > d_B \land \neg\text{AltPressed}) \\
P_A, & \text{nếu } (d_A \le d_B \land \neg\text{AltPressed}) \lor (d_A > d_B \land \text{AltPressed})
\end{cases}$$
+200
View File
@@ -0,0 +1,200 @@
# UHM Editor - ma trận thao tác
Cập nhật: 2026-05-22.
Tài liệu này là checklist thao tác cho editor ở `/editor/[id]`. Mục tiêu là trả lời nhanh 4 câu hỏi khi thêm hoặc audit một tính năng:
- Người dùng thao tác ở đâu?
- State nào bị đổi?
- Có cần undo không, undo đang dùng action nào?
- Commit snapshot có bị ảnh hưởng không?
Nguồn chính:
- `src/app/editor/[id]/page.tsx`
- `src/app/editor/[id]/featureCommands.ts`
- `src/uhm/lib/editor/state/useEditorState.ts`
- `src/uhm/lib/editor/project/useProjectCommands.ts`
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
## 1. Quy ước phân loại
### Cần undo
Một thao tác cần undo nếu nó đổi dữ liệu sẽ đi vào commit snapshot hoặc đổi draft geometry chính:
- `mainDraft.features`
- `snapshotEntityRows`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
- `replays`
- `activeReplayDraft.detail`
### Không cần undo
Một thao tác không cần undo nếu nó chỉ đổi trạng thái xem/điều hướng tạm thời:
- `mode`
- selection/focus/hover
- timeline year/filter UI
- background layer visibility
- geometry visibility local
- image trace overlay
- resize panel
- search query/result
- status message
### Undo action hiện có
| Action | Phạm vi | Ý nghĩa |
| --- | --- | --- |
| `create` | main draft | Gỡ geometry vừa tạo |
| `delete` | main draft | Khôi phục geometry đã xóa, có `index` để trả về vị trí cũ |
| `update` | main draft | Khôi phục `geometry` trước khi sửa vertex/circle |
| `properties` | main draft | Khôi phục `feature.properties` trước khi patch |
| `snapshot_entities` | snapshot | Khôi phục collection entity snapshot |
| `snapshot_wikis` | snapshot | Khôi phục collection wiki snapshot |
| `snapshot_entity_wiki` | snapshot | Khôi phục collection entity-wiki snapshot |
| `replay` | replay | Khôi phục một replay theo geometry id |
| `replays` | replay collection | Khôi phục toàn bộ `replays[]` |
| `replay_session` | replay mode | Khôi phục `activeReplayDraft` trong phiên replay |
| `group` | tổng hợp | Gom nhiều undo action thành một thao tác logic |
## 2. Geometry draft
| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
| --- | --- | --- | --- | --- | --- |
| Vẽ polygon | `draw` mode, map drawing engine | Thêm feature vào `mainDraft` | `create` | `geometries[]`, `geometry_entity[]` nếu sau đó bind entity | Feature mới mặc định `type: country`, `geometry_preset: polygon`, chưa có entity |
| Tạo point | `add-point` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: city`, `geometry_preset: point` |
| Vẽ line | `add-line` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: defense_line`, `geometry_preset: line` |
| Vẽ path/route | `add-path` mode | Thêm feature vào `mainDraft` | `create` | Như trên | Mặc định `type: attack_route`, render thêm arrow layer |
| Vẽ circle | `add-circle` mode | Thêm polygon có `circle_center`, `circle_radius` | `create` | Như trên | Mặc định `type: war`, `geometry_preset: circle-area` |
| Import GEO từ search | Search `geo`, nút import | Thêm feature vào `mainDraft`, thêm entity ref nếu thiếu | `group` gồm `snapshot_entities``create` khi cả hai đổi | `geometries[]` và entity ref | Giữ nguyên timeline filter hiện tại |
| Chọn geometry | Click map/panel | `selectedFeatureIds` | Không | Không | Chỉ là UI state |
| Focus geometry từ panel | `GeometryBindingPanel` row click | Selection, `geometryFocusRequest`, có thể kéo timeline draft year về `time_start` | Không | Không | Không đổi dữ liệu commit |
| Sửa vertex/circle | Map edit engine trong `select` | `feature.geometry` | `update` | `geometries[]` | Không hoạt động trong replay mode |
| Sửa type/time metadata | `SelectedGeometryPanel` apply | `feature.properties.type/time_start/time_end/geometry_preset` | `properties` hoặc `group` khi multi-select | `geometries[]` | Validate time parse được và `time_start <= time_end` |
| Xóa một geometry | Map delete hoặc selected panel | Xóa feature khỏi `mainDraft` | `delete`, có thể group với `replays` | `geometries[]`, `geometry_entity[]` delete delta | Prune replay/target ids liên quan geometry bị xóa |
| Xóa nhiều geometry | Bulk selected panel/map callback | Xóa nhiều feature | `group` nhiều `delete`, có thể kèm `replays` | Như trên | Undo khôi phục theo index cũ |
| Ẩn/hiện geometry local | Eye button, map hide callback | `geometryVisibility` | Không | Không | Local UI only, không đi snapshot |
| Geometry status panel | `GeometryBindingPanel` | Derived từ draft/timeline/visibility | Không | Không | Hiện `no entity`, `no time`, `partial time`, `timeline`, `out timeline`, `hidden`, `bound`, `new`; ID chỉ nằm trong tooltip |
## 3. Geometry bound_with
| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
| --- | --- | --- | --- | --- | --- |
| Bind entity vào selected geometry | `ProjectEntityRefsPanel` checkbox | `entity_id`, `entity_ids`, `entity_name`, `entity_names` trên selected features | `properties` hoặc `group` | `geometry_entity[]` | Multi-select chỉ hợp lệ khi cùng shape type |
| Unbind entity | `ProjectEntityRefsPanel` checkbox | Các field entity trên feature | `properties` hoặc `group` | `geometry_entity[]` delete delta nếu baseline có link | Commit/submit chặn geometry không còn entity |
| Bind geometry-geometry | `GeometryBindingPanel` lock button | `child.properties.bound_with = selectedGeometryId` | `properties` | `geometries[].bound_with` | Geometry con lưu id cha; không thay thế entity binding |
| Unbind geometry-geometry | `GeometryBindingPanel` unlock button | `child.properties.bound_with = null` | `properties` | `geometries[].bound_with` | Không ảnh hưởng `geometry_entity[]` |
| Bind nhiều geometry vào target | Map bind callback | `bound_with` của từng source geometry | `properties` hoặc `group` | `geometries[].bound_with` | Tự bỏ target id khỏi source ids và chặn cycle |
| Toggle binding filter | `GeometryBindingPanel` filter checkbox | `geometryBindingFilterEnabled` | Không | Không | Chỉ lọc hiển thị map theo selection/bound_with |
## 4. Entity snapshot
| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
| --- | --- | --- | --- | --- | --- |
| Add entity ref từ search | Search `entity`, nút add | `snapshotEntityRows`, `entityCatalog` | `snapshot_entities` nếu collection đổi | `entities[]` với `source: ref`, `operation: reference` | Không gọi API create entity |
| Tạo entity local | `ProjectEntityRefsPanel` create form | `snapshotEntityRows`, `entityCatalog`, reset form | `snapshot_entities` | `entities[]` với `source: inline`, `operation: create` | Validate name bắt buộc, không trùng tên, time hợp lệ |
| Sửa entity trong project | Entity row edit | `snapshotEntityRows` | `snapshot_entities` | `entities[]` update/reference theo source | Validate name và time |
| Copy selected geometry time vào form entity | Entity panel button | Form state | Không | Không | Chỉ tiện ích UI |
## 5. Wiki và entity-wiki
| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
| --- | --- | --- | --- | --- | --- |
| Add wiki ref từ search | Search `wiki`, nút add | `snapshotWikis`, active wiki request | `snapshot_wikis` nếu collection đổi | `wikis[]` với `source: ref`, `operation: reference` | Không fetch lại toàn bộ project |
| Tạo/sửa wiki local | `WikiSidebarPanel` | `snapshotWikis` | `snapshot_wikis` | `wikis[]` | `doc` ưu tiên HTML string, plaintext là fallback |
| Import HTML vào wiki | `WikiSidebarPanel` import | `snapshotWikis` sau khi lưu | `snapshot_wikis` | `wikis[]` | File import không tự commit |
| Export wiki | `WikiSidebarPanel` export | Không đổi editor state | Không | Không | Tạo file tải xuống phía browser |
| Xóa wiki khỏi snapshot | `WikiSidebarPanel` remove | `snapshotWikis` và các `snapshotEntityWikiLinks` trỏ tới wiki | `group` gồm `snapshot_wikis``snapshot_entity_wiki` | `wikis[]`, `entity_wiki[]` delta | Đây là thao tác kép, phải undo cùng nhau |
| Bind entity-wiki | `EntityWikiBindingsPanel` | `snapshotEntityWikiLinks` | `snapshot_entity_wiki` | `entity_wiki[]` với `binding` hoặc `reference` theo baseline | Link mới dùng `operation: binding` |
| Unbind entity-wiki | `EntityWikiBindingsPanel` | `snapshotEntityWikiLinks` | `snapshot_entity_wiki` | `entity_wiki[]` delete delta nếu baseline có link | Runtime chỉ remove row, snapshot builder sinh delta |
| Chèn wiki link trong editor Quill | Wiki toolbar custom link | `doc` của wiki đang sửa | `snapshot_wikis` khi lưu wiki | `wikis[].doc` | Link có thể là slug local/global hoặc marker `__missing__` |
## 6. Replay
| Thao tác | Entry point | State đổi | Undo | Snapshot/commit | Ghi chú |
| --- | --- | --- | --- | --- | --- |
| Vào replay mode | Selected geometry panel, replay button | `mode`, `activeReplayId`, `activeReplayDraft`, `replayDraft` | Không cho việc mở mode | Không trực tiếp | Nếu đổi replay đang mở, session cũ được flush |
| Tạo seed replay | `switchReplayContext` | `activeReplayDraft` với `geometry_id`, `target_geometry_ids`, `detail` | Không ngay lúc seed | `replays[]` khi mutate/flush | MAIN geo luôn đứng đầu target list |
| Sửa replay detail | `ReplayTimelineSidebar`, `ReplayEffectsSidebar` | `activeReplayDraft.detail` | `replay_session` | `replays[].detail` qua `effectiveReplays` | Replay mode không mutate geometry |
| Undo trong replay mode | Undo button khi `mode === replay` | `activeReplayDraft` | Pop `replayUndoStack` | Có nếu session còn dirty | Undo chính và undo replay tách stack |
| Thoát/chuyển replay | Exit hoặc đổi context | Flush `activeReplayDraft` vào `replays[]` | `replay` nếu flush có đổi | `replays[]` | Commit đọc `effectiveReplays`, nên không cần thoát replay trước commit |
| Xóa geometry có replay | Delete geometry | `mainDraft`, có thể prune `replays[]` | `group` với `replays` | `geometries[]`, `replays[]` | Target ids bị xóa cũng được prune |
| Preview replay | Preview overlay | Preview session, hidden ids, preview year | Không | Không | Chỉ là mô phỏng UI/map |
## 7. Timeline, map style và panel status
| Thao tác | State đổi | Undo | Commit | Ghi chú |
| --- | --- | --- | --- | --- |
| Đổi timeline year | `timelineDraftYear` | Không | Không | Client-side filter |
| Bật/tắt timeline filter | `timelineFilterEnabled` | Không | Không | Áp dụng cho cả geometry mới trong session |
| Geometry bị timeline lọc | Derived `mapRenderDraft` | Không | Không | Panel hiện `timeline` hoặc `out timeline`; selection/panel metadata vẫn đọc `editor.draft` |
| Geometry mồ côi | Derived từ `normalizeFeatureEntityIds(feature).length === 0` | Không riêng | Commit/submit bị chặn | Map không đổi màu riêng cho orphan; panel hiện `no entity` |
| Thiếu time | Derived từ `time_start/time_end` | Không riêng | Vẫn commit được | Panel hiện `no time` hoặc `partial time` |
| Selected style trên map | Feature-state selected | Không | Không | Vẫn giữ highlight selected màu xanh |
| Background layer visibility | `backgroundVisibility`, localStorage | Không | Không | UI preference |
## 8. Image overlay trace
| Thao tác | State đổi | Undo | Commit | Ghi chú |
| --- | --- | --- | --- | --- |
| Pick image overlay | `imageOverlay`, object URL | Không | Không | Overlay để trace, không vào snapshot |
| Paste image overlay | `imageOverlay`, object URL | Không | Không | Cần browser clipboard permission |
| Đổi opacity | `imageOverlay.opacity` | Không | Không | UI only |
| Dời/scale bằng keyboard | `imageOverlay.coordinates` | Không | Không | UI only |
| Remove overlay | `imageOverlay = null`, revoke URL | Không | Không | Không ảnh hưởng draft |
## 9. Project lifecycle
| Thao tác | Entry point | State đổi | Undo | Snapshot/API | Validation |
| --- | --- | --- | --- | --- | --- |
| Mở project | Project panel/open route | Reset session state, `baselineFeatureCollection`, baseline snapshot | Không | Fetch project/commit snapshot | Nếu có pending changes khi đổi project thì confirm bỏ thay đổi |
| Tạo project mới | Project panel | Project list, active project, baseline empty | Không | API create project | Title bắt buộc |
| Commit | `CommitPanel` | Baseline snapshot, `baselineFeatureCollection`, clear undo/changes | Không undo sau commit | `createProjectCommit` với `buildEditorSnapshot` | Chặn nếu không có thay đổi, chặn orphan geometry, guard payload lớn |
| Submit | Submit modal | Submission status | Không | `submitSection` | Chỉ submit khi không pending save và không orphan geometry |
| Restore commit | Commit history | Reset draft/snapshot/session theo commit | Không | Fetch/convert commit snapshot | Chặn nếu còn pending changes; không đổi head trên BE |
| Delete pending submission lock | Banner unlock | `blockedPendingSubmissionId`, mở lại project | Không | `deleteSubmission` | Dùng khi backend báo project đang bị pending submission khóa |
## 10. Undo coverage checklist
Khi thêm một thao tác mới, kiểm theo thứ tự này:
1. Thao tác có đổi `mainDraft`, snapshot collection hoặc replay detail không?
2. Nếu có, nó phải đi qua một trong các API undoable:
- `editor.createFeature`
- `editor.createFeatureWithSnapshotEntityRows`
- `editor.updateFeature`
- `editor.deleteFeature` hoặc `editor.deleteFeatures`
- `editor.patchFeatureProperties` hoặc `editor.patchFeaturePropertiesBatch`
- `editor.setSnapshotEntityRows`
- `editor.setSnapshotWikis`
- `editor.setSnapshotEntityWikiLinks`
- `editor.setSnapshotWikisAndEntityWikiLinks`
- `editor.mutateActiveReplay`
3. Nếu thao tác đổi nhiều vùng state trong cùng một ý nghĩa người dùng, dùng `group`.
4. Nếu xóa geometry, kiểm replay target/replay collection có cần prune không.
5. Nếu xóa wiki, kiểm entity-wiki links trỏ tới wiki đó có cần xóa cùng undo không.
6. Nếu thao tác có thể tạo geometry không entity, commit/submit guard vẫn phải bắt được.
7. Nếu thao tác chỉ đổi UI view/filter/focus, ghi rõ là không undo và không snapshot.
## 11. Snapshot checklist
Khi một thao tác cần đi vào commit, kiểm output snapshot:
- Geometry body nằm trong `geometries[]`.
- Geometry-entity relation nằm trong `geometry_entity[]`, không chỉ trong `feature.properties.entity_ids`.
- Entity rows nằm trong `entities[]`.
- Wiki rows nằm trong `wikis[]`.
- Entity-wiki rows nằm trong `entity_wiki[]`.
- Replay script nằm trong `replays[]`, không lưu `replayDraft`.
- Generate-only fields trên feature như `entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_label_candidates`, `time_start`, `time_end`, `bound_with`, `type` được snapshot builder xử lý/loại bỏ đúng chỗ trước API payload.
## 12. Các thao tác cần audit lại nếu editor đổi lớn
- Multi-select khác shape hiện bị chặn ở bind entity/geometry, nhưng selected panel vẫn phải giữ rule này nếu thêm action mới.
- Timeline filter đang là client-side, nếu sau này fetch theo timeline từ backend thì `timelineStatus` trong panel cần đổi nguồn truth.
- Image overlay hiện không persist. Nếu cần lưu overlay vào project, phải thêm snapshot schema và undo.
- Background visibility hiện là localStorage. Nếu cần lưu theo project/user, phải tách khỏi nhóm UI-only.
- Replay mode hiện không mutate geometry. Nếu cho sửa geometry trong replay, phải thiết kế lại undo và commit boundary.
+198
View File
@@ -0,0 +1,198 @@
# UHM Editor - replay actions catalog
Cập nhật: 2026-05-25.
Tài liệu này mô tả action catalog của replay editor/preview hiện tại. Shape chuẩn nằm ở `src/uhm/types/projects.ts`; dispatcher runtime nằm ở `src/uhm/lib/replay/replayDispatcher.ts`.
## 1. Replay shape
```ts
type BattleReplay = {
id: string;
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
type ReplayStage = {
id: number;
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
type ReplayStep = {
duration: number;
use_UI_function: ReplayAction<UIOptionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_geo_function: ReplayAction<GeoFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
type ReplayAction<T> = {
function_name: T;
params: unknown[];
};
```
Ghi chú:
- `use_narrow_function` là tên field hiện tại cho nhóm narrative.
- `params` là tuple positional, không phải object schema.
- `target_geometry_ids` là source truth cho replay draft; không persist `replayDraft`.
- `detail_time_start/detail_time_stop` là string theo form replay hiện tại, không phải `time_start/time_end` số của geometry.
## 2. Runtime execution order
Preview flatten replay thành danh sách step theo thứ tự stage/step.
Trong mỗi step, dispatcher chạy các group action từ step hiện tại. Duration của step quyết định thời gian chờ trước step tiếp theo. Preview state có thể đổi:
- map camera/labels
- timeline visible/filter/year
- hidden geometry ids
- title/descriptions/subtitle/dialog/image/toast
- wiki sidebar/open wiki
- preview layer panel / zoom controls
- temporary geometry effects
- playback speed
Stop/reset preview khôi phục presentation state, map/timeline baseline, label visibility và dọn toàn bộ temporary geometry effects.
## 3. UI actions
| Action | Params | Runtime hiện tại |
| --- | --- | --- |
| `timeline` | `[visible: boolean]` | Ẩn/hiện TimelineBar trong preview |
| `layer_panel` | `[visible: boolean]` | Ẩn/hiện panel layer trong preview |
| `wiki_panel` | `[visible: boolean]` | Mở/đóng wiki sidebar preview |
| `close_wiki_panel` | `[]` | Đóng wiki sidebar và clear active wiki |
| `zoom_panel` | `[visible: boolean]` | Ẩn/hiện cụm zoom/projection control trên map preview |
| `wiki` | `[wikiId: string]` | Mở wiki sidebar và active wiki id |
| `toast` | `[message: string]` | Hiện toast tạm thời |
| `playback_speed` | `[speed: number]` | Đổi tốc độ phát preview |
Legacy shape vẫn được dispatcher đọc:
```ts
{ function_name: "UI", params: [optionName, ...payload] }
```
Shape mới nên dùng trực tiếp:
```ts
{ function_name: "timeline", params: [true] }
```
## 4. Map actions
| Action | Params | Runtime hiện tại |
| --- | --- | --- |
| `set_camera_view` | `[state]` | `map.easeTo` center/zoom/pitch/bearing/duration |
| `set_time_filter` | `[year: number]` | Set replay preview timeline year |
| `toggle_labels` | `[visible: boolean]` | Legacy labels toggle |
| `show_labels` | `[]` | Hiện symbol text labels |
| `hide_labels` | `[]` | Ẩn symbol text labels |
| `show_all_geometries` | `[]` | Clear hidden geometry ids |
| `reset_camera_north` | `[]` | Set bearing về 0 |
`set_camera_view` chấp nhận center dạng `[lng, lat]` hoặc `{ lng, lat }`.
## 5. Geo actions
| Action | Params | Runtime hiện tại |
| --- | --- | --- |
| `fly_to_geometry` | `[geometryId]` | Legacy: fly tới một geometry |
| `fly_to_geometries` | `[geometryIds, duration?]` | Fit/fly tới nhiều geometry |
| `set_geometry_visibility` | `[geometryIds, visible]` | Legacy: show/hide theo boolean |
| `show_geometries` | `[geometryIds]` | Bỏ ids khỏi hidden set |
| `hide_geometries` | `[geometryIds]` | Thêm ids vào hidden set |
| `fit_to_geometries` | `[geometryIds, duration?]` | Legacy: dùng fly/fit tới geometry |
| `orbit_camera_around_geometry` | `[geometryId, zoom?, pitch?, turns?, duration?]` | Ease camera quanh bbox geometry |
| `pulse_geometry` | `[geometryId, color?, repeat?, duration?]` | Pulse overlay tạm thời, tự cleanup |
| `animate_dashed_border` | `[geometryId, color?, width?, speed?, duration?]` | Dashed border overlay tạm thời, tự cleanup |
| `set_geometry_style` | `[geometryIds, fill?, opacity?, stroke?, width?]` | Style overlay trong preview tới khi stop/reset |
| `show_geometry_label` | `[geometryId, text?, color?, size?]` | Hiện label riêng trong preview tới khi stop/reset |
| `follow_geometry_path` | `[geometryId, duration?, zoom?, pitch?]` | Camera chạy theo tọa độ path geometry |
| `follow_geometries_path` | `[geometryIds, duration?, zoom?, pitch?]` | Camera chạy theo chuỗi path geometry |
| `dim_other_geometries` | `[geometryIds]` | Chỉ hiện target ids, ẩn các geometry khác |
| `set_as_background_geometries` | `[geometryIds]` | Đặt các geometry làm background (luôn hiển thị, không bị ẩn) |
| `remove_from_background_geometries` | `[geometryIds]` | Loại bỏ các geometry khỏi danh sách background |
Các visual effect dùng overlay source/layer riêng và không mutate geometry draft.
## 6. Narrative actions
| Action | Params | Runtime hiện tại |
| --- | --- | --- |
| `set_dialog` | `[data: { text: string; image_url?: string } \| null]` | Set dialog box text and optional image, or clear it if null |
| `set_title` | `[title: string]` | Set title overlay |
| `clear_title` | `[]` | Clear title |
| `set_descriptions` | `[text: string]` | Set description overlay |
| `clear_descriptions` | `[]` | Clear descriptions |
| `show_dialog_box` | `[avatar, text, side, speaker?]` | Legacy: hiện dialog, nay được normalize sang `set_dialog` |
| `clear_dialog_box` | `[]` | Clear dialog |
| `display_historical_image` | `[url, caption?]` | Legacy: hiện image overlay, nay được normalize sang `set_dialog` |
| `clear_historical_image` | `[]` | Clear image |
| `set_step_subtitle` | `[subtitle: string | null]` | Set subtitle |
| `clear_step_subtitle` | `[]` | Clear subtitle |
## 7. Composer shortcuts hiện có
Map shortcuts:
- `show_labels`
- `hide_labels`
- `set_time_filter`
- `reset_camera_north`
- `show_all_geometries`
Geo shortcuts:
- `fly_to_geometries`
- `follow_geometries_path`
- `show_geometries`
- `hide_geometries`
- `pulse_geometry`
- `animate_dashed_border`
- `orbit_camera_around_geometry`
- `show_geometry_label`
- `dim_other_geometries`
- `set_geometry_style`
- `set_as_background_geometries`
- `remove_from_background_geometries`
Narrative composer hiện hỗ trợ đầy đủ các narrative actions ở mục 6.
Timeline action list hỗ trợ reorder, duplicate, delete và edit `params` trực tiếp bằng JSON array có validate nhẹ. Composer bên phải vẫn là đường chính để tạo action mới.
## 8. Normalization và migration
Khi load snapshot:
- Replay thiếu `geometry_id` có thể fallback từ `id`.
- `target_geometry_ids` được normalize/dedupe, MAIN geo đứng đầu.
- Snapshot cũ có `replay_features` được chuyển thành `target_geometry_ids`.
- UI legacy action `{ function_name: "UI", params: [...] }` được normalize sang option action.
- Unknown action/function bị bỏ qua trong normalize/dispatcher.
- Normalizer snapshot hiện giữ các action đang có trong type/UI, gồm `close_wiki_panel`, `show_all_geometries` và các narrative `clear_*`.
## 9. Undo và commit boundary
- Replay mode dùng `replayUndoStack`, tách khỏi main undo.
- Sửa stage/step/action đi qua `editor.mutateActiveReplay`.
- Mỗi mutation tạo `replay_session` undo action.
- Thoát hoặc chuyển replay flush session vào `replays[]`.
- Commit đọc `editor.effectiveReplays`, nên có thể commit khi vẫn đang ở replay mode.
- Replay mode hiện không cho create/update/delete geometry.
## 10. Checklist khi thêm replay action
1. Thêm function name vào `src/uhm/types/projects.ts`.
2. Thêm label/summary trong `ReplayTimelineSidebar`.
3. Thêm composer hoặc shortcut trong `ReplayEffectsSidebar`.
4. Thêm runtime trong `replayDispatcher.ts` và action module phù hợp.
5. Thêm normalize support trong `editorSnapshot.ts`.
6. Xác định action có cần reset khi stop preview không.
7. Cập nhật file này và `commit_snapshot.ts`.
+246
View File
@@ -0,0 +1,246 @@
# UHM Editor - snapshot contract
Cập nhật: 2026-05-22.
Tài liệu này mô tả ranh giới dữ liệu giữa editor runtime và commit payload. Nếu `editor_operations.md` trả lời "thao tác nào đổi gì", file này trả lời "commit gửi shape nào và vì sao".
Nguồn chính:
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
- `src/uhm/doc/commit_snapshot.ts`
- `src/uhm/types/projects.ts`
- `src/uhm/types/geo.ts`
## 1. Luồng build commit
Luồng hiện tại:
1. `commitSection()` kiểm tra project đang mở, pending changes và orphan geometry.
2. `editor.buildPayload()` lấy geometry diff để xác định operation.
3. `buildEditorSnapshot()` nhận `mainDraft`, snapshot collections, `effectiveReplays`, `previousSnapshot`.
4. Commit API nhận snapshot đã qua `toApiEditorSnapshot()`.
5. Sau commit thành công, FE chuyển snapshot mới về session shape bằng `toEditorSessionSnapshot()` và reset baseline.
Payload API:
```ts
{
snapshot_json: EditorSnapshot;
edit_summary: string;
}
```
`toApiEditorSnapshot()` hiện normalize thêm:
- `time_start/time_end`: ép về `number|null` nếu field tồn tại ở feature/entity/geometry.
- `geometries[].type`: đổi type key FE sang backend type code string hoặc `null`.
- `replays[]`: normalize `id`, `geometry_id`, `target_geometry_ids`, `detail`.
## 2. Root snapshot shape
| Field | Nguồn runtime | Ý nghĩa |
| --- | --- | --- |
| `editor_feature_collection` | Clone từ `mainDraft` đã bỏ field generate-only | FeatureCollection runtime phục vụ load lại editor |
| `entities` | `snapshotEntityRows` + entity ids phát hiện từ geometry | Entity rows inline/ref |
| `geometries` | `mainDraft.features` + deleted ids từ diff | Geometry rows có operation |
| `geometry_entity` | `feature.properties.entity_ids/entity_id` so với baseline | Join table geometry-entity |
| `wikis` | `snapshotWikis` so với baseline | Wiki rows inline/ref/delete |
| `entity_wiki` | `snapshotEntityWikiLinks` so với baseline | Join table entity-wiki |
| `replays` | `editor.effectiveReplays` | Script replay, không chứa `replayDraft` |
Root fields optional ở type vì FE còn phải đọc snapshot cũ/partial, nhưng commit mới nên sinh đủ các collection có liên quan.
## 3. Geometry contract
### `geometries[]`
Mỗi feature trong `mainDraft.features` sinh một row:
| Field | Rule |
| --- | --- |
| `id` | `String(feature.properties.id)` |
| `source` | Luôn `"inline"` cho geometry đang tồn tại trong draft |
| `operation` | `"create"`, `"update"` hoặc `"reference"` theo baseline/diff |
| `type` | FE type key trước `toApiEditorSnapshot()`, backend code string sau normalize API |
| `draw_geometry` | `feature.geometry` |
| `bound_with` | `normalizeFeatureBoundWith(feature)` |
| `time_start` / `time_end` | `feature.properties.time_start/time_end ?? null` |
| `bbox` | BBox tính từ geometry, hoặc `null` |
Snapshot legacy có `binding: string[]` trên geometry cha được FE migrate khi load bằng cách invert sang `bound_with` trên từng geometry con.
Geometry đã bị xóa sinh row:
```ts
{
id,
source: "ref",
operation: "delete"
}
```
### Operation rule
`operation` của geometry đang tồn tại được tính theo thứ tự:
- Nếu snapshot trước đã đánh dấu row này `create`, giữ `create`.
- Nếu không có previous feature và đang có previous snapshot hoặc feature chưa persisted, là `create`.
- Nếu id nằm trong geometry changes hoặc feature khác previous snapshot, là `update`.
- Còn lại là `reference`.
## 4. FeatureCollection runtime contract
`editor_feature_collection` giữ geometry để load lại editor, nhưng trước khi đưa vào snapshot FE xóa các field generate-only khỏi `feature.properties`:
- `type`
- `time_start`
- `time_end`
- `bound_with`
- `entity_id`
- `entity_ids`
- `entity_name`
- `entity_names`
- `entity_label_candidates`
- `entity_type_id`
Các field này được lưu ở collection chuẩn hơn:
- `type/time/bound_with` nằm ở `geometries[]`.
- entity relation nằm ở `geometry_entity[]`.
- entity label/name được hydrate lại từ `entities[]` và join table khi load.
## 5. Geometry-entity contract
Join table chính là `geometry_entity[]`, không phải field denormalized trên feature.
Runtime source:
- `normalizeFeatureEntityIds(feature)`
- Ưu tiên `entity_ids[]` hợp lệ.
- Fallback `entity_id` nếu `entity_ids` rỗng.
Build rule:
- Link hiện có trong baseline và vẫn còn trong draft: `operation: "reference"`.
- Link mới trong draft: `operation: "binding"`.
- Link có trong baseline nhưng không còn trong draft: `operation: "delete"`.
Rows được dedupe/sort theo `geometry_id`, rồi `entity_id`.
Commit/submit hiện chặn nếu có geometry không có entity ids hợp lệ. Geometry-geometry `bound_with` không được tính là đã bind entity.
## 6. Entity contract
`entities[]` được build từ:
- `snapshotEntityRows` hiện tại.
- Entity ids xuất hiện trong `geometry_entity[]` nhưng chưa có row entity, được bổ sung row ref tối thiểu.
Row tối thiểu:
```ts
{
id: string;
source: "inline" | "ref";
operation?: "create" | "update" | "delete" | "reference";
name?: string;
description?: string | null;
time_start?: number;
time_end?: number;
}
```
Quy ước:
- Entity backend/search thêm vào snapshot dùng `source: "ref"`, `operation: "reference"`.
- Entity tạo local dùng `source: "inline"`, `operation: "create"`.
- Sửa entity inline có thể giữ `create` nếu chưa commit hoặc thành `update`.
## 7. Wiki contract
`wikis[]` đến từ `snapshotWikis` so với baseline.
Row chính:
```ts
{
id: string;
source: "inline" | "ref";
operation?: "create" | "update" | "delete" | "reference";
title: string;
slug?: string | null;
doc: string | null;
}
```
Rule xóa:
- Nếu wiki có trong baseline nhưng không còn trong `snapshotWikis`, snapshot builder thêm row `operation: "delete"`.
- Khi UI xóa wiki, FE cũng xóa các `snapshotEntityWikiLinks` trỏ tới wiki đó trong cùng undo group.
`doc` hiện ưu tiên HTML string. Plaintext là fallback cho dữ liệu cũ.
## 8. Entity-wiki contract
Runtime source là `snapshotEntityWikiLinks`.
Build rule tương tự geometry-entity:
- Link có trong baseline và vẫn còn: `reference`.
- Link mới: `binding`.
- Link bị remove so với baseline: `delete`.
Rows được dedupe/sort theo `entity_id`, rồi `wiki_id`.
## 9. Replay contract
Commit gửi `replays[]` từ `editor.effectiveReplays`.
Canonical shape:
```ts
{
id: string;
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
}
```
Rule:
- `id` hiện bằng `geometry_id`.
- `target_geometry_ids` được normalize, MAIN geo đứng đầu.
- `detail` là danh sách stage/step/action.
- Không gửi `replayDraft` hoặc `replay_features`.
Snapshot cũ có `replay_features` được FE migrate sang `target_geometry_ids` khi load.
## 10. Validation trước commit/submit
FE chặn commit nếu:
- Chưa mở project.
- Không có pending changes.
- Có orphan geometry.
- Payload JSON vượt guardrail kích thước hiện tại khoảng 3.5MB.
FE chặn submit nếu:
- Project chưa có head commit.
- Còn pending changes chưa commit.
- Có orphan geometry.
Missing/partial time hiện chỉ là trạng thái panel, không chặn commit.
## 11. Checklist khi đổi snapshot
Khi thêm field/collection mới:
1. Cập nhật type runtime trong `src/uhm/types`.
2. Cập nhật `src/uhm/doc/commit_snapshot.ts`.
3. Cập nhật `buildEditorSnapshot()``toEditorSessionSnapshot()` nếu field cần round-trip.
4. Cập nhật `toApiEditorSnapshot()` nếu backend cần shape khác runtime.
5. Cập nhật undo nếu thao tác chỉnh field đó là user-facing persistent action.
6. Cập nhật dirty detection/pending save count nếu collection mới độc lập với geometry.
7. Cập nhật `editor_operations.md` và manual checklist.
+230
View File
@@ -0,0 +1,230 @@
# UHM Editor - state replay hiện tại
Tài liệu này mô tả đúng flow replay mode hiện tại của `/editor/[id]`.
Nguồn thật:
- `src/app/editor/[id]/page.tsx`
- `src/uhm/lib/editor/state/useEditorState.ts`
- `src/uhm/lib/editor/project/useProjectCommands.ts`
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
- `src/uhm/doc/editor_replay_actions.md`
## 1. Kết luận ngắn
Replay mode hiện tại có 2 lớp state:
- `activeReplayDraft`
-`BattleReplay` đang chỉnh
- chỉ chứa `id`, `geometry_id`, `target_geometry_ids`, `detail`
- `replayDraft`
-`FeatureCollection` local, được FE hydrate lại từ `mainDraft + target_geometry_ids`
- chỉ dùng để map/render/select trong replay mode
Điểm quan trọng:
- `replayDraft` không còn được persist vào commit/API
- commit chỉ lưu `replays[]` với `target_geometry_ids`
- snapshot cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load
## 2. Shape replay hiện tại
```ts
type BattleReplay = {
id: string;
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
```
Ý nghĩa:
- `geometry_id`
- MAIN geo của replay
- cũng là key để tìm replay tương ứng
- `id`
- hiện luôn bằng `geometry_id`
- thêm để schema replay có id riêng rõ ràng hơn
- `target_geometry_ids`
- toàn bộ geo được đưa vào replay
- phần tử đầu nên luôn là MAIN geo
- `detail`
- stage/step/actions của kịch bản
## 3. Replay được mở như thế nào
Khi vào replay từ UI:
1. editor lấy `triggerId`
- ưu tiên `selectedFeatureIds[0]`
- nếu chưa có selection thì dùng `featureId` vừa click
2. gọi `editor.switchReplayContext(triggerId, selectedFeatureIds)`
3. `switchReplayContext()` sẽ:
- flush replay cũ nếu đang mở replay khác
- tìm replay đã tồn tại theo `geometry_id`
- nếu chưa có thì tạo seed mới
## 4. Seed replay được tạo ra sao
Replay seed mới có dạng:
```ts
{
id: triggerId,
geometry_id: triggerId,
target_geometry_ids: [...],
detail: []
}
```
`target_geometry_ids` được build từ:
- MAIN geo
- toàn bộ bulk selection hiện tại
- toàn bộ geometry con có `bound_with` trỏ tới MAIN geo trong `mainDraft`
Rule hiện tại:
- MAIN geo luôn đứng đầu
- geo trùng sẽ được dedupe
- nếu replay đã tồn tại sẵn, FE giữ `detail` cũ và chỉ append thêm geo mới còn thiếu vào `target_geometry_ids`
## 5. `replayDraft` được hydrate thế nào
`replayDraft` không còn nằm trong snapshot.
Mỗi lần:
- mở replay
- undo replay session
- restore `activeReplayDraft`
FE sẽ hydrate lại:
```ts
replayDraft = hydrate(mainDraft, activeReplayDraft.target_geometry_ids)
```
Hydrate hiện tại:
- lấy feature từ `mainDraft` theo đúng thứ tự `target_geometry_ids`
- clone ra `FeatureCollection` mới
- flatten `bound_with` thành `null` để các geo trong replay bình đẳng với nhau
## 6. Trong replay mode map đang đọc gì
`useEditorState()` vẫn switch active draft như cũ:
```ts
const activeDraft = mode === "replay" ? replayDraft : mainDraft;
```
Nên khi `mode === "replay"`:
- `editor.draft` trỏ vào `replayDraft`
- `editor.draftRef` trỏ vào `replayDraftRef`
- map chỉ render tập geo đang nằm trong `target_geometry_ids`
`editor.draftRef` ở đây là ref nội bộ của editor state; map interaction dùng tên `renderDraftRef` để tránh nhầm với draft commit chính.
Khi `mode === "replay_preview"`, page dùng `previewSession.draft` và replay preview state để tạo `mapRenderDraft` rồi render/ẩn geometry. Mode này không mutate `replayDraft` hoặc `mainDraft`.
## 7. Replay mode còn sửa geometry không
Không.
Hiện tại state layer đã chặn toàn bộ nhánh mutate geometry trong replay mode:
- `createFeature`
- `createFeatureWithSnapshotEntityRows`
- `patchFeatureProperties`
- `patchFeaturePropertiesBatch`
- `updateFeature`
- `deleteFeature`
Nghĩa là:
- replay mode chỉ còn là nơi viết script replay
- không còn persist hay commit geometry edit riêng của replay
## 8. Cái gì vẫn được sửa trong replay mode
Replay sidebar vẫn sửa:
- `detail[]`
- `stage`
- `step`
- các action `UI / map / geo / narrative`
Các thay đổi đó đi qua:
- `editor.mutateActiveReplay`
- `applyReplaySessionMutation()`
Undo replay vẫn riêng ở:
- `replayUndoStack`
Danh sách action và tuple `params` nằm ở `editor_replay_actions.md`.
## 9. Khi nào replay được flush về `replays[]`
`activeReplayDraft` chỉ là session đang mở.
Nó được flush về `replays[]` khi:
- thoát replay mode
- chuyển sang replay khác
Hàm chịu trách nhiệm là:
- `finalizeActiveReplaySession()`
## 10. Commit lấy replay từ đâu
Commit không lấy `activeReplayDraft` trực tiếp.
Nó lấy:
- `editor.effectiveReplays`
`effectiveReplays` là:
- `replays`
- cộng thêm overlay của `activeReplayDraft` nếu session hiện tại đã thay đổi nhưng chưa flush
Vì vậy:
- đang còn ở replay mode vẫn commit được replay mới nhất
- không cần thoát replay mode mới lưu được script
## 11. Replay đi qua API ra sao
Payload commit hiện tại chỉ gửi:
- `geometry_id`
- `target_geometry_ids`
- `detail`
Không gửi:
- `replayDraft`
- `replay_features`
- `FeatureCollection` local của replay mode
## 12. Migrate dữ liệu cũ
Snapshot cũ nếu còn:
```ts
replay_features?: FeatureCollection
```
thì FE sẽ:
- đọc `replay_features.features[].properties.id`
- chuyển chúng thành `target_geometry_ids`
- bỏ `replay_features` khỏi runtime replay mới
Nên dữ liệu cũ vẫn mở được, nhưng commit mới sẽ ra schema mới.
+348
View File
@@ -0,0 +1,348 @@
# UHM Editor - state và vòng đời dữ liệu
Tài liệu này mô tả state thật đang được dùng bởi editor hiện tại.
Entry point chính là `useEditorSessionState()``useEditorState()`.
## 1. Hai lớp state chính
Editor đang tách làm hai khối:
- `useEditorSessionState()`
- state UI, session, form, project, timeline, background, wiki
- `useEditorState(baselineFeatureCollection, snapshotUndo)`
- state draft hình học, diff và undo
Nói ngắn gọn:
- `session state` quyết định editor đang nhìn cái gì và panel đang thao tác gì
- `editor state` quyết định geometry nào đang tồn tại trong draft và khác baseline ra sao
## 2. State geometry trung tâm
### `baselineFeatureCollection`
- Nằm ở `useEditorSessionState()`
-`FeatureCollection` baseline được nạp vào editor khi mở project hoặc restore commit
- Khi thay đổi, `useEditorState()` sẽ reset toàn bộ draft và baseline tương ứng
### `mainDraft`
- Nằm trong `useEditorState()`
- Là working copy geometry chính dùng cho edit/commit
- Mọi thao tác create/update/delete geometry đều đi qua đây
### `editor.draft`
- Là draft đang active theo mode
- Ở mode thường trỏ tới `mainDraft`
- Ở mode `replay` trỏ tới `replayDraft`
- Panel metadata/selection đọc từ đây, không đọc từ `mapRenderDraft`
### `draftRef`
- Ref nội bộ tương ứng với draft trong `useEditorState()`
- Được dùng để luôn đọc được state mới nhất mà không phải rebind callback liên tục
- Không nhầm với `renderDraftRef` trong `Map.tsx`, vốn là dữ liệu đang render/interact trên map
### `initialMapRef`
- `Map<featureId, Feature>` tạo từ `baselineFeatureCollection`
- Là baseline để tính diff giữa draft hiện tại và dữ liệu gốc của session
### `changes`
- Kết quả `diffDraftToInitial(draft, initialMapRef.current)`
- Map theo `feature.properties.id`
- Mỗi phần tử có thể là:
- `create`
- `update`
- `delete`
Lưu ý: diff hiện chỉ là cơ chế nhận biết geometry nào đã thay đổi so với baseline. Snapshot commit thực tế vẫn được build từ toàn bộ `draft` cộng với các snapshot bảng phụ.
### `changeCount`
- Số lượng geometry thay đổi hiện tại
- Được cộng thêm dirty state của wiki/entity/entity-wiki/replay để tạo `pendingSaveCount`
## 3. Undo state
Undo được quản lý bởi `useUndoStack()`.
Kiểu action hiện có:
- `create`
- `delete`
- `update`
- `properties`
- `snapshot_entities`
- `snapshot_wikis`
- `snapshot_entity_wiki`
- `replay`
- `replays`
- `replay_session`
- `group`
Ý nghĩa:
- geometry create/delete/update/properties undo được trực tiếp trên `draft`
- snapshot entity/wiki/link undo được apply qua `snapshotUndo` API truyền vào `useEditorState`
- `replay`/`replays` undo các thay đổi script replay đã flush vào collection chính
- `replay_session` undo các thay đổi stage/step/action khi đang ở mode `replay`
- `group` dùng để gom nhiều thay đổi thành một thao tác undo logic
Editor hiện có `undo`, nhưng chưa có redo.
## 4. Session state theo nhóm
### 4.1. Mode và selection
- `mode: EditorMode`
- `selectedFeatureIds`
- `selectedGeometryEntityIds`
`selectedFeatureIds` là state gốc cho:
- panel metadata geometry
- bind entity
- bind geometry
- focus geometry từ search/binding panel
### 4.2. Form state
- `entityForm`
- dùng cho form tạo entity local
- `geometryMetaForm`
- `type_key`
- `time_start`
- `time_end`
Geometry-geometry bound state không nằm trong `geometryMetaForm`; `GeometryBindingPanel` chỉnh trực tiếp `feature.properties.bound_with` của geometry con.
### 4.3. Replay state
Replay state nằm trong `useEditorState()`:
- `replays`
- collection script đã flush vào state chính
- `activeReplayDraft`
- `BattleReplay` đang sửa trong mode `replay`
- `replayDraft`
- `FeatureCollection` hydrate từ `mainDraft + activeReplayDraft.target_geometry_ids`
- `effectiveReplays`
- `replays` cộng overlay của `activeReplayDraft` nếu session hiện tại đã đổi nhưng chưa flush
Undo của replay session dùng stack riêng khi `mode === "replay"`.
`replay_preview` là session preview trong page, dùng `previewSession`/`useReplayPreview()` và không persist. Khi thoát các chế độ preview, editor sẽ dọn dẹp hoàn toàn các map effects, highlight, và khôi phục camera view state & projection (Globe/Flat) ban đầu trước khi vào preview.
### 4.4. Project/session task state
`useProjectSessionState()` gom các cờ async vào một state machine nhỏ:
- `sectionTask: "idle" | "saving" | "submitting" | "opening-project"`
Từ đó sinh ra:
- `isSaving`
- `isSubmitting`
- `isOpeningSection`
Ngoài ra còn có:
- `activeSection`
- `projectState`
- `sectionCommits`
- `baselineSnapshot`
- `commitTitle`
### 4.5. Timeline state
`useTimelineState()` giữ:
- `timelineYear`
- `timelineDraftYear`
- `isTimelineLoading`
- `timelineStatus`
Trong page hiện tại, timeline filter đang dùng `timelineDraftYear`.
Không có fetch dữ liệu project theo `timelineYear`; timeline đang là client-side visibility filter.
### 4.6. Background/session UI
`useBackgroundSessionState()` giữ:
- `backgroundVisibility`
- `isBackgroundVisibilityReady`
Giá trị thật được load từ `localStorage` key `uhm.backgroundLayerVisibility.v1`.
### 4.7. Wiki/session state
`useWikiSessionState()` giữ:
- `snapshotWikis`
- `snapshotEntityWikiLinks`
Đây là single source of truth cho phần wiki trong snapshot commit.
### 4.8. Preview session states và refs (Viewer / Replay Preview)
Các states và refs điều khiển preview được khai báo trực tiếp trong `page.tsx`:
- `previewSession: ReplayPreviewSession | null`
- Đóng băng toàn bộ snapshot collections (replays, draft, entities, wikis, links) cùng timeline, filter và camera view state khi chạy preview.
- `previewAutoplayMode: "start" | "selection" | null`
- Trạng thái autoplay (bắt đầu từ đầu hay từ step được chọn) của Replay Preview.
- `previewWikiCache`, `previewWikiError`, `isPreviewWikiLoading`
- Cache và status để hiển thị nội dung Wiki tương tác trong sidebar preview.
- `previewFeaturePopupAnchor: MapFeaturePayload | null`
- Neo tọa độ/payload của popup hiển thị thông tin geometry khi click trên map ở preview mode.
- `previewActiveEntityId`, `isPreviewEntitySidebarOpen`
- Sidebar hiển thị chi tiết entity được chọn trong preview.
- `previewLinkEntityPopup: PreviewLinkEntityPopupState | null`
- Trạng thái popup điều hướng sang entity khác khi click vào link wiki trong preview.
- `editorOriginalMapViewStateRef: ReturnType<MapHandle["getViewState"]> | null`
- Ref lưu giữ camera view state và projection (Globe/Flat) ban đầu của editor trước khi bắt đầu preview, phục vụ việc khôi phục hoàn toàn bản đồ khi exit.
- `replayPreviewReturnRef: { mode: "replay" | "preview"; session: ReplayPreviewSession | null }`
- Ref ghi nhận session và mode trước đó khi chuyển tiếp từ Viewer Preview sang Replay Preview, cho phép quay trở lại đúng Viewer Preview khi click thoát Replay Preview.
## 5. Snapshot state
Editor đang làm việc với các snapshot collection chính ngoài geometry:
- `snapshotEntityRows`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
- `replays` / `effectiveReplays`
Chúng đại diện cho "current session snapshot", không phải danh sách delta thô.
Ví dụ:
- entity ref được giữ bằng `operation: "reference"`
- entity/wiki local mới tạo có thể mang `operation: "create"`
- link entity-wiki mới tạo dùng `operation: "binding"`
Khi commit, `buildEditorSnapshot()` sẽ so với `baselineSnapshot` để chuyển các collection này thành snapshot đúng semantic cho backend.
## 6. Baseline snapshot là gì
`baselineSnapshot` là snapshot đang được xem như gốc của session hiện tại.
Nó được cập nhật khi:
- mở project
- commit thành công
- restore từ một commit
`baselineSnapshot` được dùng để:
- biết link nào là `reference`, link nào là `binding`, link nào là `delete`
- biết wiki/entity nào là thay đổi thực sự so với snapshot trước
- giữ lại inline entity/wiki từ snapshot trước nếu user chưa xóa chúng
## 7. Derived state quan trọng trong page
### `mapRenderDraft`
-`FeatureCollection` duy nhất trong page quyết định geometry nào được truyền xuống map
- nguồn có thể là `mainDraft`, `replayDraft`, hoặc preview draft tùy mode
- đã qua filter timeline nếu `timelineFilterEnabled = true`
- đã qua replay preview hidden ids nếu đang preview
- geometry mới tạo trong session cũng bị timeline filter xử lý như geometry baseline
### `labelContextBaseDraft` và `mapLabelContextDraft`
- chỉ dùng để enrich/lookup label entity cho map
- có thể chứa geometry bị `mapRenderDraft` lọc ra
- không được dùng để quyết định geometry nào render trên map
### `geometryChoices`
- nguồn dữ liệu cho `GeometryBindingPanel`
- thêm trạng thái derived như orphan entity, time completeness, timeline visibility, hidden/bound/new
- ID geometry không phải label chính của row, nhưng vẫn nằm trong tooltip/title
### `snapshotEntityRowsVisible`
- loại bỏ các row `delete`
- dedupe theo `id`
### `selectedFeatures`
- map từ `selectedFeatureIds` sang feature thật trong `editor.draft.features`
### `isMultiEditValid`
- chỉ `true` khi tất cả geometry đang chọn cùng `geometry.type`
- một số thao tác bind sẽ chặn nếu giá trị này là `false`
### `pendingSaveCount`
Được tính như sau:
- `editor.changeCount`
- `+1` nếu wiki dirty
- `+1` nếu entities dirty
- `+1` nếu entity-wiki dirty
- `+1` nếu replay dirty
Đây là con số dùng trong UI commit, không phải số record backend chắc chắn sẽ thay đổi.
## 8. Dirty detection
Dirty check của:
- `snapshotWikis`
- `snapshotEntityRows`
- `snapshotEntityWikiLinks`
- `editor.effectiveReplays`
đều đang làm bằng cách normalize trước rồi so `JSON.stringify`.
Điều này đủ thực dụng cho snapshot cỡ vừa, nhưng cần lưu ý:
- không tối ưu cho dữ liệu rất lớn
- phụ thuộc vào tính ổn định của thứ tự mảng sau normalize
## 9. State được persist vào localStorage
Hiện editor chỉ persist hai nhóm nhỏ:
- background layer visibility
- key: `uhm.backgroundLayerVisibility.v1`
- map projection
- key: `uhm:mapProjection`
Editor hiện không persist toàn bộ draft/project snapshot vào localStorage.
Nếu cần autosave local draft, đó là tính năng phải làm thêm, không phải behavior hiện tại.
## 10. Khi nào state bị reset
### Reset toàn phần
Xảy ra khi:
- mở project khác
- mở lại project
- restore commit
Hiệu ứng:
- `baselineFeatureCollection` đổi
- `useEditorState()` reset `draft`
- `undoStack` bị clear
- baseline map được build lại
### Reset cục bộ
- đổi selection có thể reset `geometryMetaForm`
- đóng/mở wiki modal không reset snapshot wiki, chỉ reset form local của modal
## 11. Một số giới hạn hiện tại cần nhớ khi đọc code
-`undo`, chưa có `redo`
- timeline state có `timelineYear`, nhưng page hiện dùng `timelineDraftYear` cho filtering
- dirty count của commit không tương ứng một-một với số mutation backend
- map selection, bound_with filter và timeline filter đều là state client-side
- trạng thái orphan/time/timeline trong `GeometryBindingPanel` là derived từ draft + visibility, không phải field persist riêng
+253
View File
@@ -0,0 +1,253 @@
# Replay Export JSON
Tài liệu này mô tả đúng payload mà nút `Export JSON` của replay đang xuất ra hiện tại.
Nguồn thật:
- `src/uhm/components/editor/ReplayTimelineSidebar.tsx`
- `src/uhm/types/projects.ts`
- `src/uhm/doc/editor_replay_actions.md`
## 1. Kết luận ngắn
Export hiện tại có dạng:
```json
{
"exported_at": "2026-05-17T12:34:56.000Z",
"geometry_id": "geo-main-id",
"current_replay": { "...": "BattleReplay hiện tại" },
"snapshot_fragment": {
"replays": [
{ "...": "chính current_replay" }
]
}
}
```
Trong đó:
- `current_replay` là replay đang edit
- `snapshot_fragment.replays[0]` là cùng replay đó, nhưng đặt vào đúng chỗ trong commit snapshot
## 2. Root payload
```ts
type ReplayExportPayload = {
exported_at: string;
geometry_id: string;
current_replay: BattleReplay;
snapshot_fragment: {
replays: BattleReplay[];
};
};
```
Ý nghĩa:
- `exported_at`
- timestamp ISO lúc bấm export
- chỉ để debug
- `geometry_id`
- copy nhanh từ `current_replay.geometry_id`
- `current_replay`
- replay draft hiện tại
- `snapshot_fragment`
- fragment để test replay này nếu đặt vào commit snapshot thật
## 3. Shape của `current_replay`
```ts
type BattleReplay = {
id: string;
geometry_id: string;
target_geometry_ids: string[];
detail: ReplayStage[];
};
```
Ý nghĩa:
- `geometry_id`
- MAIN geo của replay
- `id`
- hiện luôn bằng `geometry_id`
- `target_geometry_ids`
- toàn bộ geo thuộc replay
- phần tử đầu nên luôn là MAIN geo
- `detail`
- stage/step/actions của replay script
## 4. `target_geometry_ids` là gì
Đây là phần thay thế cho `replay_features` cũ.
FE không còn export/persist cả `FeatureCollection` riêng của replay nữa. Thay vào đó chỉ lưu:
- geo MAIN
- các geo được đưa vào replay từ bulk select
- các geometry con có `bound_with` trỏ tới MAIN geo
Khi mở replay, FE sẽ hydrate lại `replayDraft` từ:
- `mainDraft`
- `target_geometry_ids`
## 5. Shape của `detail`
```ts
type ReplayStage = {
id: number;
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
```
```ts
type ReplayStep = {
duration: number;
use_UI_function: ReplayAction<UIOptionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_geo_function: ReplayAction<GeoFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
```
Ý nghĩa:
- `stage` là cụm lớn theo mốc thời gian hoặc nhịp kể chuyện
- `step` là đơn vị phát nhỏ hơn trong một stage
- `duration` là trọng số thời gian của step
- action hiện tách thành 4 nhóm
## 6. Ví dụ JSON gần thực tế
```json
{
"exported_at": "2026-05-17T12:34:56.000Z",
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"current_replay": {
"id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"target_geometry_ids": [
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7",
"019e13ab-5896-713a-111111111111"
],
"detail": [
{
"id": 0,
"title": "Mở đầu chiến dịch",
"detail_time_start": "1939",
"detail_time_stop": "1940",
"steps": [
{
"duration": 1000,
"use_UI_function": [
{
"function_name": "timeline",
"params": [false]
}
],
"use_map_function": [
{
"function_name": "set_time_filter",
"params": [1939]
}
],
"use_geo_function": [
{
"function_name": "fly_to_geometries",
"params": [
[
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7"
]
]
}
],
"use_narrow_function": [
{
"function_name": "set_title",
"params": ["Chiến dịch bắt đầu"]
}
]
}
]
}
]
},
"snapshot_fragment": {
"replays": [
{
"id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
"target_geometry_ids": [
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7",
"019e13ab-5896-713a-111111111111"
],
"detail": [
{
"id": 0,
"title": "Mở đầu chiến dịch",
"detail_time_start": "1939",
"detail_time_stop": "1940",
"steps": [
{
"duration": 1000,
"use_UI_function": [
{
"function_name": "timeline",
"params": [false]
}
],
"use_map_function": [
{
"function_name": "set_time_filter",
"params": [1939]
}
],
"use_geo_function": [
{
"function_name": "fly_to_geometries",
"params": [
[
"019e13ab-4823-76c5-afde-2391c0cf311d",
"019e13ab-6063-713d-a28f-98a1556817a7"
]
]
}
],
"use_narrow_function": [
{
"function_name": "set_title",
"params": ["Chiến dịch bắt đầu"]
}
]
}
]
}
]
}
]
}
}
```
## 7. Cách đọc file export
Khi nhìn file export:
- nếu cần biết replay bám vào geo nào, xem `geometry_id`
- nếu cần biết replay gồm những geo nào, xem `target_geometry_ids`
- nếu cần biết script sẽ làm gì, xem `detail[].steps[]`
- nếu cần so với commit snapshot, xem `snapshot_fragment.replays`
## 8. Ghi chú quan trọng
- Export hiện tại không còn chứa `replay_features`
- Nếu mở replay cũ từng dùng `replay_features`, FE sẽ migrate sang `target_geometry_ids` trước khi export
- `current_replay``snapshot_fragment.replays[0]` hiện vẫn là cùng một replay, chỉ khác góc nhìn
+449
View File
@@ -0,0 +1,449 @@
# Goong APIs In Use
Mục tiêu của tài liệu này:
- mô tả **chính xác** frontend hiện tại đang dùng gì từ Goong
- mô tả **backend cần proxy gì** để giấu `api_key`
- mô tả **response nào phải sanitize/rewrite**
- tránh liệt kê thừa các API Goong mà app hiện tại không đụng tới
Phạm vi kiểm tra:
- [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:1)
- [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:1)
- [useMapLayers.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/map/useMapLayers.ts:1)
- style JSON đã tải về:
- [goong_map_web.json](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/tmp/goong-styles/goong_map_web.json)
- [goong_satellite.json](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/tmp/goong-styles/goong_satellite.json)
## 1. Tóm tắt kỹ thuật
Frontend hiện tại **không** `map.setStyle(goongStyleJson)` trực tiếp.
Thay vào đó:
1. app tự `fetch()` 2 style JSON của Goong qua backend proxy
2. app parse style JSON để lấy:
- `raster source` từ `goong_satellite.json`
- `sources + layers` cần thiết từ `goong_map_web.json`
3. nếu source dùng `url`, app tiếp tục fetch source manifest qua proxy trong `tiles.ts`
4. app rewrite `tiles[]` về backend proxy rồi `map.addSource(...)``map.addLayer(...)` thủ công
5. từ thời điểm đó, **MapLibre tự request tiếp** tile/font URLs đã là URL proxy
Hệ quả:
- nếu BE chỉ proxy `assets/*.json` thì **chưa đủ**
- proxy phải cover style JSON, source manifest, tile URLs và glyph PBF
- frontend hiện không nhúng `api_key` trong URL; backend proxy chịu trách nhiệm gọi upstream bằng key server-side nếu upstream yêu cầu
## 2. Luồng request thật hiện tại
### 2.1. App fetch style JSON qua proxy
Frontend gọi:
1. `${API_BASE_URL}/proxy/tiles.goong.io/assets/goong_satellite.json`
2. `${API_BASE_URL}/proxy/tiles.goong.io/assets/goong_map_web.json`
Upstream gốc trong code vẫn là:
1. `https://tiles.goong.io/assets/goong_satellite.json`
2. `https://tiles.goong.io/assets/goong_map_web.json`
Nguồn trong code:
- `GOONG_SATELLITE_STYLE_UPSTREAM_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:8)
- `GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:9)
- `buildGoongProxyUrl(...)` ở [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:29)
- `loadGoongStyleDocument(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:199)
Mục đích:
- `goong_satellite.json`
- app lấy ra raster source đầu tiên
- dùng làm nền satellite
- `goong_map_web.json`
- app lấy ra các layer/source phục vụ:
- `Country Borders`
- `Province Borders`
- `District Borders`
- `Country Labels`
- `Rivers`
### 2.2. Frontend fetch source manifests qua proxy
Khi style source có field `url`, `tiles.ts` tự fetch source manifest qua proxy trước khi gọi `map.addSource(...)`.
Các source URL đang xuất hiện trong style JSON:
#### Trong `goong_satellite.json`
- `https://tiles.goong.io/sources/satellite.json?api_key=...`
- `https://tiles.goong.io/sources/base.json?api_key=...`
- `https://tiles.goong.io/sources/goong.json?api_key=...`
#### Trong `goong_map_web.json`
- `https://tiles.goong.io/sources/base.json?api_key=...`
- `https://tiles.goong.io/sources/goong.json?api_key=...`
Ý nghĩa:
- `sources/satellite.json`
- raster source manifest cho nền satellite
- `sources/base.json`
- vector source manifest cho các lớp `boundary`, `worldcountriespoints`, `worldnationalcapitals`
- `sources/goong.json`
- vector source manifest cho các lớp `riversandlakes`, `vietnam_administrator`
### 2.3. MapLibre fetch tile URLs đã rewrite
Đây là phần dễ bị bỏ sót nhất.
Khi `tiles.ts` đã tải `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, nó rewrite mọi URL trong field:
- `tiles[]`
về `${API_BASE_URL}/proxy/tiles.goong.io/...`, rồi mới đưa source spec cho MapLibre.
Tức là runtime thật của frontend hiện tại là:
1. FE fetch style JSON qua proxy
2. FE fetch source manifest qua proxy
3. FE rewrite `tiles[]` về proxy
4. MapLibre fetch tile URL đã rewrite
Nếu backend muốn che key hoàn toàn, thì backend proxy phải xử lý cả các tile URL này bằng key server-side.
## 3. Những upstream Goong resource đang dùng thật
Tính theo runtime hiện tại, upstream Goong đang được dùng thật là:
### 3.1. Style JSON
- `assets/goong_satellite.json`
- `assets/goong_map_web.json`
### 3.2. Source manifests
- `sources/satellite.json`
- `sources/base.json`
- `sources/goong.json`
### 3.3. Tile endpoints bên trong source manifests
- raster tile URLs nằm trong `sources/satellite.json`
- vector tile URLs nằm trong `sources/base.json`
- vector tile URLs nằm trong `sources/goong.json`
Lưu ý:
- tile URL pattern chính xác phải đọc từ source manifest upstream ở runtime
- backend không nên hardcode khi chưa xác minh nội dung `tiles[]`
- frontend hiện giữ nguyên upstream target path trong proxy URL sau khi strip `api_key`
## 4. Những thứ frontend hiện tại dùng thêm hoặc KHÔNG dùng
### 4.1. Goong glyphs / fonts
Style JSON của Goong có field:
- `glyphs: https://tiles.goong.io/fonts/{fontstack}/{range}.pbf?api_key=...`
Flow hiện tại **có dùng glyphs của Goong qua proxy**.
Map đang trỏ `glyphs` vào:
- `${API_BASE_URL}/proxy/tiles.goong.io/fonts/{fontstack}/{range}.pbf`
Nguồn trong code:
- [useMapLayers.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/map/useMapLayers.ts:17)
- [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:12)
Kết luận:
- **backend proxy Goong fonts/glyphs là bắt buộc cho flow hiện tại**
### 4.2. Goong sprite
Style JSON của Goong có:
- `sprite: https://tiles.goong.io/sprite`
Nhưng flow hiện tại **không phụ thuộc sprite** vì:
- app không nạp toàn bộ Goong style vào map
- app chỉ nhặt `sources``layers`
- khi clone overlay labels, code còn chủ động loại bớt icon fields
Nguồn trong code:
- `cloneOverlayLayer(...)` ở [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:411)
Kết luận:
- **không cần backend proxy Goong sprite cho flow hiện tại**
### 4.3. Các REST API khác của Goong
Preview search hiện có dùng trực tiếp các REST API này từ browser:
- `Place/AutoComplete`
- `Place/Detail`
- `Geocode` reverse geocoding với `latlng=lat,lng`
Nguồn trong code:
- [goongPlaces.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/goongPlaces.ts:1)
- [PresentPlaceSearch.tsx](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/editor/PresentPlaceSearch.tsx:1)
Chưa dùng:
- directions
- distance matrix
- static map
## 5. Backend cần làm gì
### 5.1. Mục tiêu backend
Backend phải đảm bảo:
1. browser không gọi Goong trực tiếp
2. browser không nhìn thấy `api_key`
3. frontend vẫn nhận được dữ liệu theo format mà MapLibre/app hiện tại cần
### 5.2. Hai kiểu triển khai
Có 2 cách:
#### Cách A: Transparent proxy
BE trả về gần như đúng response của Goong, nhưng strip/sanitize mọi `api_key` lồng trong JSON.
Frontend hiện tự wrap các upstream URL đó bằng `buildGoongProxyUrl(...)`.
Ưu điểm:
- gần với Goong
- ít phải đổi frontend hơn
Nhược điểm:
- BE phải sanitize JSON response để không lộ key trong body response
#### Cách B: Normalize thành API nội bộ
BE không trả nguyên style/source của Goong mà trả dữ liệu đã xử lý sẵn cho FE.
Ưu điểm:
- hợp đồng BE-FE rõ hơn
- ít phụ thuộc format Goong hơn
Nhược điểm:
- cần sửa frontend nhiều hơn
Với frontend hiện tại, **Cách A** là hợp lý nhất.
Lưu ý quan trọng: frontend hiện mong nhận `sources.*.url``tiles[]` ở dạng upstream URL hoặc relative URL. Không rewrite các URL này thành `/proxy/...` trong JSON response hiện tại, vì FE sẽ tự gọi `buildGoongProxyUrl(...)`; rewrite sẵn sẽ dễ bị double-proxy.
## 6. Contract backend được khuyến nghị
### 6.1. Proxy style JSON
#### `GET /proxy/tiles.goong.io/assets/goong_satellite.json`
Upstream:
- `https://tiles.goong.io/assets/goong_satellite.json?api_key=<server-side-key>`
Backend phải:
- fetch upstream bằng key server-side
- parse JSON
- strip `api_key` khỏi `sources.*.url`, `glyphs`, `sprite` nếu các field đó xuất hiện trong body
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- có thể giữ nguyên các field khác
Response:
- `Content-Type: application/json`
- body: style JSON đã sanitize, chưa rewrite sang `/proxy/...`
#### `GET /proxy/tiles.goong.io/assets/goong_map_web.json`
Upstream:
- `https://tiles.goong.io/assets/goong_map_web.json?api_key=<server-side-key>`
Backend phải:
- fetch upstream bằng key server-side
- parse JSON
- strip `api_key` khỏi `sources.*.url`, `glyphs`, `sprite` nếu các field đó xuất hiện trong body
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- có thể giữ nguyên các field khác
Response:
- `Content-Type: application/json`
- body: style JSON đã sanitize, chưa rewrite sang `/proxy/...`
### 6.2. Proxy source manifests
#### `GET /proxy/tiles.goong.io/sources/satellite.json`
Upstream:
- `https://tiles.goong.io/sources/satellite.json?api_key=<server-side-key>`
Backend phải:
- fetch upstream
- parse JSON
- strip `api_key` khỏi mọi URL trong `tiles[]`
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên metadata quan trọng:
- `tileSize`
- `minzoom`
- `maxzoom`
- `bounds`
- `scheme`
- `attribution`
Response:
- `Content-Type: application/json`
- body: source manifest đã sanitize, chưa rewrite sang `/proxy/...`
#### `GET /proxy/tiles.goong.io/sources/base.json`
Upstream:
- `https://tiles.goong.io/sources/base.json?api_key=<server-side-key>`
Backend phải:
- fetch upstream
- parse JSON
- strip `api_key` khỏi mọi URL trong `tiles[]`
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên metadata tilejson khác
#### `GET /proxy/tiles.goong.io/sources/goong.json`
Upstream:
- `https://tiles.goong.io/sources/goong.json?api_key=<server-side-key>`
Backend phải:
- fetch upstream
- parse JSON
- strip `api_key` khỏi mọi URL trong `tiles[]`
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên metadata tilejson khác
### 6.3. Proxy tile endpoints
Backend bắt buộc phải có route để trả tile thật.
Frontend hiện build URL proxy generic theo upstream target:
- `GET /proxy/tiles.goong.io/...`
Yêu cầu:
- request browser -> backend
- backend -> upstream Goong bằng key server-side
- stream response về browser
- pass through hoặc preserve:
- `Content-Type`
- `Cache-Control`
- `ETag`
- `Last-Modified`
Response type có thể là:
- raster image
- vector tile protobuf
## 7. Runtime dependency map cho BE
### 7.1. Satellite background
Luồng:
1. FE đọc `goong_satellite.json`
2. FE lấy `sources.satellite`
3. FE gọi `sources/satellite.json` qua proxy trong `tiles.ts`
4. FE rewrite `tiles[]` về proxy URL
5. MapLibre gọi raster tile URLs đã rewrite
BE cần cover:
- style JSON
- source manifest
- raster tile URLs
### 7.2. Overlay borders / labels / rivers
Luồng:
1. FE đọc `goong_map_web.json`
2. FE lấy selected layers + selected sources
3. FE gọi `sources/base.json` qua proxy trong `tiles.ts`
4. FE gọi `sources/goong.json` qua proxy trong `tiles.ts`
5. FE rewrite `tiles[]` về proxy URL
6. MapLibre gọi vector tile URLs đã rewrite
BE cần cover:
- style JSON
- 2 source manifests
- vector tile URLs tương ứng
## 8. Danh sách tối thiểu BE phải cover
Nếu chỉ làm đúng những gì frontend hiện tại dùng, checklist tối thiểu là:
1. proxy `tiles.goong.io/assets/goong_satellite.json`
2. proxy `tiles.goong.io/assets/goong_map_web.json`
3. proxy `tiles.goong.io/sources/satellite.json`
4. proxy `tiles.goong.io/sources/base.json`
5. proxy `tiles.goong.io/sources/goong.json`
6. proxy `tiles.goong.io/fonts/{fontstack}/{range}.pbf`
7. proxy toàn bộ tile URL được khai báo trong `sources/satellite.json`
8. proxy toàn bộ tile URL được khai báo trong `sources/base.json`
9. proxy toàn bộ tile URL được khai báo trong `sources/goong.json`
## 9. Những gì BE chưa cần làm ngay
Cho flow hiện tại, BE **chưa cần**:
- proxy Goong `sprite`
- proxy geocoding / directions / autocomplete
Điều này chỉ đúng khi frontend vẫn giữ kiến trúc hiện tại.
Nếu sau này frontend chuyển sang `map.setStyle(goongStyleJson)` trực tiếp, hãy đánh giá lại:
- `glyphs`
- `sprite`
vì khi đó chúng có thể trở thành dependency bắt buộc.
## 10. Gợi ý ngắn cho team BE
Nếu muốn làm ít rủi ro nhất:
1. làm proxy `assets/*.json`
2. sanitize nested `api_key` trong style JSON
3. làm proxy `sources/*.json`
4. sanitize nested `api_key` trong source manifests
5. làm proxy generic cho tile
6. làm proxy Goong fonts/glyphs
Nếu sanitize JSON thiếu thì key có thể lộ ngay trong response style/source. Nếu proxy tile/font thiếu thì map background hoặc labels có thể không tải được.
+133
View File
@@ -0,0 +1,133 @@
# Goong Map Web Structure
Nguồn JSON gốc được tải về tại:
- `FrontEndUser/tmp/goong-styles/goong_map_web.json`
File này là style vector/label đầy đủ hơn, phù hợp để dò:
- water và water labels
- boundary theo cấp
- place labels cho lịch sử
## Mermaid overview
```mermaid
graph TD
ROOT[goong_map_web.json]
ROOT --> S1[source: base]
ROOT --> S2[source: composite]
S1 --> B1[source-layer: boundary]
S1 --> B2[source-layer: worldcountriespoints]
S1 --> B3[source-layer: worldnationalcapitals]
S2 --> C1[source-layer: riversandlakes]
S2 --> C2[source-layer: rivernames]
S2 --> C3[source-layer: lakenames]
S2 --> C4[source-layer: vietnam_administrator]
S2 --> C5[source-layer: streets_label]
B1 --> BL0[boundary-land-type-0 / type-0-bg]
B1 --> BL1[boundary-land-type-1 / type-1-bg]
B1 --> BL2[boundary-land-type-2 / type-2-bg]
B2 --> PC1[place-country-1]
B2 --> PC2[place-country-2]
B3 --> CAP0[place-city-capital]
C1 --> W1[water]
C1 --> W2[water-shadow]
C2 --> RN0[river-name-0]
C2 --> RN1[river-name-1]
C2 --> RN2[river-name-2]
C3 --> LN0[lake-name_priority_0]
C3 --> LN1[lake-name_priority_1]
C3 --> LN2[lake-name_priority_2]
C4 --> VA0[place-city-capital-vietnam]
C4 --> VA1[place-city1 / place-city2]
C4 --> VA2[place-town1 / place-town2]
C4 --> VA3[place-suburb / borough / neighbourhood]
C4 --> VA4[place-village]
C5 --> RD0[highway-name-minor]
C5 --> RD1[highway-name-medium]
C5 --> RD2[highway-name-major]
```
## Boundary layers
Các layer boundary nổi bật:
- `boundary-land-type-0-bg`
- `boundary-land-type-0`
- `boundary-land-type-1-bg`
- `boundary-land-type-1`
- `boundary-land-type-2-bg`
- `boundary-land-type-2`
Minzoom quan sát được:
- `type-0`: từ zoom `1`
- `type-1`: từ zoom `5`
- `type-2-bg`: từ zoom `7`
- `type-2`: từ zoom `13`
Suy luận thực dụng:
- `type-0` có khả năng là biên giới quốc gia
- `type-1` có khả năng là cấp tỉnh/thành
- `type-2` có khả năng là cấp sâu hơn
## Water layers
Water fill:
- `water`
- `water-shadow`
Water labels:
- `river-name-0`
- `river-name-1`
- `river-name-2`
- `lake-name_priority_0`
- `lake-name_priority_1`
- `lake-name_priority_2`
## Place labels
Những label đáng quan tâm cho historical use:
- `place-country-1`
- `place-country-2`
- `place-city-capital`
- `place-city-capital-vietnam`
- `place-city1`
- `place-city2`
- `place-town1`
- `place-town2`
Những label dễ gây rối nếu bật nhiều:
- `highway-name-*`
- `place-suburb*`
- `place-neighbourhood*`
- `place-village`
## Gợi ý mapping cho UI
Mapping hiện tại trong `tiles.ts` là heuristic runtime, không hardcode đúng từng id này:
- `Country Borders` -> ưu tiên `boundary-land-type-0`, bỏ `boundary-land-type-0-bg`
- `Province Borders` -> ưu tiên `boundary-land-type-1`, bỏ `boundary-land-type-1-bg`
- `District Borders` -> `boundary-land-type-2` và các layer cấp sâu hơn
- `Country Labels` -> symbol layer có text field và tên/source-layer giống country/admin/place/city/town/capital
- `Rivers` -> line/fill layer có tên/source-layer giống water/waterway/river/stream/canal/lake/reservoir/sea/ocean
Water label symbol như `river-name-*`/`lake-name_*` chỉ được đưa vào nếu heuristic sau này mở rộng; code hiện tại chủ yếu lấy line/fill water.
+404
View File
@@ -0,0 +1,404 @@
# Goong Proxy Backend Guide
Tài liệu này mô tả:
- luồng request thật của frontend hiện tại
- backend cần proxy chỗ nào
- backend cần sanitize/rewrite chỗ nào
- trade-off hiệu suất nếu proxy toàn bộ Goong
- khuyến nghị triển khai thực dụng cho team BE
Tài liệu liên quan:
- [goong_apis_in_use.md](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/doc/goong_apis_in_use.md)
- [goong_map_web_structure.md](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/doc/goong_map_web_structure.md)
- [goong_satellite_structure.md](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/doc/goong_satellite_structure.md)
Code liên quan:
- [config.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/config.ts:1)
- [tiles.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/api/tiles.ts:1)
- [useMapLayers.ts](/home/amoratran/wsp/ultimate-history-map/FrontEndUser/src/uhm/components/map/useMapLayers.ts:1)
## 1. Bối cảnh hiện tại
Frontend hiện tại không `setStyle(goongStyle)` trực tiếp cho MapLibre.
Thay vào đó:
1. FE gọi style JSON qua `buildGoongProxyUrl(...)`
2. FE parse style JSON
3. FE lấy ra:
- raster source cho satellite
- selected vector sources/layers cho borders, labels, rivers
4. FE gọi source manifest qua `buildGoongProxyUrl(...)` nếu style source có `url`
5. FE rewrite `tiles[]` về proxy URL rồi `addSource()``addLayer()` thủ công
6. MapLibre request tile/font URLs đã là URL proxy
Điểm quan trọng:
- browser không được gọi trực tiếp `tiles.goong.io`
- browser vẫn sẽ đi qua backend proxy ở các tầng:
- `assets/*.json`
- `sources/*.json`
- tile URLs trong `tiles[]`
- `fonts/{fontstack}/{range}.pbf`
## 2. Luồng request hiện tại
```mermaid
sequenceDiagram
participant FE as Frontend
participant GL as MapLibre
participant BE as Backend Proxy
participant GO as Goong
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_satellite.json
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_map_web.json
BE->>GO: fetch upstream style JSON with server-side key
GO-->>BE: style JSON
BE-->>FE: sanitized style JSON
FE->>BE: GET /proxy/tiles.goong.io/sources/satellite.json
FE->>BE: GET /proxy/tiles.goong.io/sources/base.json
FE->>BE: GET /proxy/tiles.goong.io/sources/goong.json
BE->>GO: fetch upstream source manifests with server-side key
GO-->>BE: source manifests
BE-->>FE: sanitized source manifests
FE->>GL: addSource(proxy tile URLs) + addLayer(...)
GL->>BE: GET /proxy/tiles.goong.io/...tile...
GL->>BE: GET /proxy/tiles.goong.io/fonts/{fontstack}/{range}.pbf
BE->>GO: fetch upstream tile/font bytes
GO-->>BE: bytes
BE-->>GL: bytes
```
## 3. Mục tiêu của backend proxy
Nếu mục tiêu là:
- không lộ `api_key` ở browser
- vẫn giữ frontend hiện tại gần như nguyên
thì backend phải đảm bảo:
1. browser chỉ gọi domain BE
2. BE gọi Goong bằng key server-side
3. mọi URL Goong lồng bên trong JSON đều được sanitize để không chứa `api_key`
4. frontend nhận URL upstream/relative sạch để tự wrap qua `buildGoongProxyUrl(...)`
Nếu thiếu bước 3:
- `api_key` có thể lộ ngay trong response JSON ở browser devtools
## 4. Những gì cần sanitize/rewrite
### 4.1. Style JSON
Trong `goong_satellite.json``goong_map_web.json`, BE cần sanitize:
- `sources.*.url`
- `glyphs`
- `sprite`
Ví dụ:
- từ `https://tiles.goong.io/sources/base.json?api_key=...`
- thành `https://tiles.goong.io/sources/base.json`
Không rewrite sẵn thành `/proxy/...` với frontend hiện tại, vì `tiles.ts` đang tự gọi `buildGoongProxyUrl(...)`.
### 4.2. Source manifests
Trong `sources/satellite.json`, `sources/base.json`, `sources/goong.json`, BE cần sanitize:
- mọi phần tử trong `tiles[]`
Ví dụ:
- từ `https://.../{z}/{x}/{y}...api_key=...`
- thành `https://.../{z}/{x}/{y}...`
Sau đó frontend rewrite URL sạch này về `${API_BASE_URL}/proxy/tiles.goong.io/...`.
### 4.3. Những field còn phải để ý cho flow hiện tại
Với kiến trúc frontend hiện tại:
- `glyphs` đang được FE dùng qua proxy
- `sprite` hiện chưa dùng
Nghĩa là:
- BE **phải** proxy được `fonts/{fontstack}/{range}.pbf`
- BE hiện **chưa cần** proxy `sprite`
Nếu sau này FE chuyển sang `map.setStyle(goongStyleJson)` trực tiếp thì phải đánh giá lại `sprite` ngay.
## 5. Backend endpoint được khuyến nghị
### 5.1. Style endpoints
- `GET /proxy/tiles.goong.io/assets/goong_satellite.json`
- `GET /proxy/tiles.goong.io/assets/goong_map_web.json`
Nhiệm vụ:
- gọi upstream Goong bằng key server-side
- parse JSON
- strip `api_key` khỏi nested URL
- trả JSON đã sanitize, chưa rewrite nested URL sang `/proxy/...`
### 5.2. Source endpoints
- `GET /proxy/tiles.goong.io/sources/satellite.json`
- `GET /proxy/tiles.goong.io/sources/base.json`
- `GET /proxy/tiles.goong.io/sources/goong.json`
Nhiệm vụ:
- gọi upstream Goong bằng key server-side
- parse JSON
- strip `api_key` khỏi `tiles[]`
- giữ URL upstream/relative để frontend tự wrap bằng `buildGoongProxyUrl(...)`
- giữ nguyên:
- `bounds`
- `minzoom`
- `maxzoom`
- `scheme`
- `tileSize`
- `attribution`
### 5.3. Tile endpoint
Route generic frontend hiện build:
- `GET /proxy/tiles.goong.io/...`
Nhiệm vụ:
- nhận tile request từ browser
- map sang upstream tile URL tương ứng
- gọi Goong bằng key server-side nếu upstream yêu cầu
- stream response về browser
Điểm quan trọng:
- tile response không nên parse lại
- tile response nên stream/pass-through
- giữ cache headers càng nhiều càng tốt
## 6. Luồng request sau khi proxy
```mermaid
sequenceDiagram
participant FE as Frontend
participant GL as MapLibre
participant BE as Backend Proxy
participant GO as Goong
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_satellite.json
FE->>BE: GET /proxy/tiles.goong.io/assets/goong_map_web.json
BE->>GO: fetch upstream style JSON
GO-->>BE: style JSON
BE-->>FE: sanitized style JSON
FE->>BE: GET /proxy/tiles.goong.io/sources/satellite.json
FE->>BE: GET /proxy/tiles.goong.io/sources/base.json
FE->>BE: GET /proxy/tiles.goong.io/sources/goong.json
BE->>GO: fetch upstream source manifests
GO-->>BE: source manifests
BE-->>FE: sanitized source manifests
FE->>GL: addSource(proxy tile URLs) + addLayer(...)
GL->>BE: GET /proxy/tiles.goong.io/...tile...
GL->>BE: GET /proxy/tiles.goong.io/fonts/...
BE->>GO: fetch upstream tile
GO-->>BE: tile bytes
BE-->>GL: tile bytes
```
## 7. Trade-off hiệu suất
### 7.1. Sanitize JSON có chậm không?
Có overhead, nhưng **rất nhỏ** so với tile traffic.
JSON cần sanitize hiện tại chỉ gồm:
- 2 style JSON
- 3 source manifests
Những file này nhỏ, số lượng ít, và có thể cache rất mạnh.
Kết luận:
- sanitize JSON không phải bottleneck chính
### 7.2. Tile proxy mới là chỗ đắt
Chi phí hiệu suất chính nằm ở:
- mọi tile phải đi qua backend
- backend phải giữ thêm một hop mạng
- mất lợi thế gọi trực tiếp CDN của Goong từ browser
Các ảnh hưởng có thể thấy:
- tăng latency
- tăng bandwidth qua BE
- tăng CPU/memory nếu BE buffer response thay vì stream
- tăng load connection pool tới Goong
### 7.3. Nếu không proxy tile/font URL
Nếu BE chỉ proxy style/source JSON nhưng thiếu tile/font route:
- MapLibre request tile/font proxy URL sẽ lỗi
- hoặc nếu FE bị đổi để dùng URL upstream trực tiếp thì browser sẽ gọi Goong và có thể lộ key
Tức là:
- tile/font route vẫn là phần bắt buộc nếu muốn giữ kiến trúc hiện tại
## 8. Cách giảm thiểu impact hiệu suất
### 8.1. Cache sanitized JSON ở BE
Khuyến nghị:
- cache in-memory hoặc Redis cho:
- `goong_satellite.json`
- `goong_map_web.json`
- `sources/satellite.json`
- `sources/base.json`
- `sources/goong.json`
TTL có thể dài vì:
- style/source manifest không đổi liên tục
Tối ưu:
- chỉ sanitize một lần rồi reuse
### 8.2. Stream tile response
Cho tile route:
- không parse body
- không buffer toàn bộ file vào memory nếu không cần
- stream thẳng upstream -> client
### 8.3. Preserve cache headers
Với tile route, BE nên pass-through hoặc preserve:
- `Cache-Control`
- `ETag`
- `Last-Modified`
- `Content-Type`
Nếu BE/ngược phía CDN có cache tốt, impact sẽ giảm rất nhiều.
### 8.4. Dùng CDN/reverse proxy trước BE nếu có thể
Nếu production có CDN/nginx/edge cache:
- cache mạnh cho:
- sanitized style JSON
- sanitized source manifests
- tile responses
Điều này quan trọng hơn tối ưu code sanitize.
### 8.5. Đừng parse manifest ở mỗi tile request
Nên:
- sanitize source manifest một lần rồi cache
- tile route chỉ resolve target path đơn giản và forward
Không nên:
- parse lại manifest ở mỗi tile request
## 9. Recommendation thực dụng
Nếu team BE muốn giải pháp cân bằng giữa bảo mật và hiệu suất:
### Option A. Full proxy, sanitize JSON
BE cover:
1. style JSON
2. source manifests
3. tiles
4. fonts/glyphs
Ưu điểm:
- key không lộ ra browser
- FE vẫn dùng upstream target path sạch rồi tự wrap proxy URL
Nhược điểm:
- BE chịu toàn bộ traffic tile
### Option B. Hybrid
BE cover:
1. style JSON
2. source manifests
Nhưng để tile/font đi trực tiếp upstream.
Ưu điểm:
- BE nhẹ hơn
Nhược điểm:
- key vẫn lộ ở tile request
- không khớp với code hiện tại nếu `buildGoongProxyUrl(...)` vẫn được dùng cho tile/font
Kết luận:
- nếu ưu tiên bảo mật key thật sự: dùng **Option A**
- nếu ưu tiên hiệu suất hơn và chấp nhận domain restrictions của Goong: **Option B cần đổi frontend**
## 10. Recommendation cho codebase hiện tại
Với frontend hiện tại, hướng hợp lý nhất là:
1. giữ nguyên FE logic parse style/source như hiện nay
2. giữ `config.ts` dùng upstream URL sạch rồi để `buildGoongProxyUrl(...)` wrap thành `${API_BASE_URL}/proxy/tiles.goong.io/...`
3. để BE sanitize nested `api_key` trong style/source JSON, nhưng không rewrite nested URL thành `/proxy/...`
4. để BE stream tile/font response
5. cache sanitized JSON ở BE
Nói ngắn:
- sanitize JSON: bắt buộc để không lộ key trong response
- FE rewrite tile URLs bằng `buildGoongProxyUrl(...)`
- proxy tile: phần tốn hiệu suất nhất
- muốn bù hiệu suất: phải dùng cache/stream/CDN tốt
## 11. Checklist cho team BE
1. Tạo route proxy cho 2 style JSON
2. Tạo route proxy cho 3 source manifests
3. Strip `api_key` khỏi nested URL trong style JSON
4. Strip `api_key` khỏi `tiles[]` trong source manifests
5. Tạo route proxy tile generic
6. Tạo route proxy fonts/glyphs
7. Stream tile/font response
8. Preserve cache headers
9. Cache sanitized JSON
10. Kiểm tra browser không còn request trực tiếp `tiles.goong.io`
+99
View File
@@ -0,0 +1,99 @@
# Goong Satellite Structure
Nguồn JSON gốc được tải về tại:
- `FrontEndUser/tmp/goong-styles/goong_satellite.json`
File này là style satellite. Nó vẫn có boundary và labels, nhưng ít lớp nước hơn `goong_map_web.json`.
## Mermaid overview
```mermaid
graph TD
ROOT[goong_satellite.json]
ROOT --> S0[source: satellite]
ROOT --> S1[source: base]
ROOT --> S2[source: composite]
S1 --> B1[source-layer: boundary]
S1 --> B2[source-layer: worldcountriespoints]
S1 --> B3[source-layer: worldnationalcapitals]
S2 --> C1[source-layer: vietnam_administrator]
S2 --> C2[source-layer: streets_label]
B1 --> BL0[boundary-land-type-0 / type-0-bg]
B1 --> BL1[boundary-land-type-1 / type-1-bg]
B1 --> BL2[boundary-land-type-2 / type-2-bg]
B2 --> PC1[place-country-1]
B2 --> PC2[place-country-2]
B3 --> CAP0[place-city-capital]
C1 --> VA0[place-city-capital-vietnam]
C1 --> VA1[place-city1 / place-city2]
C1 --> VA2[place-town1 / place-town2]
C1 --> VA3[place-suburb / borough / neighbourhood]
C1 --> VA4[place-village]
C2 --> RD0[highway-name-minor]
C2 --> RD1[highway-name-medium]
C2 --> RD2[highway-name-major]
```
## Boundary layers
Các layer boundary nổi bật:
- `boundary-land-type-0-bg`
- `boundary-land-type-0`
- `boundary-land-type-1-bg`
- `boundary-land-type-1`
- `boundary-land-type-2-bg`
- `boundary-land-type-2`
Minzoom quan sát được:
- `type-0`: từ zoom `1`
- `type-1`: từ zoom `5`
- `type-2-bg`: từ zoom `7`
- `type-2`: từ zoom `7`
## Place labels
Labels hữu ích:
- `place-country-1`
- `place-country-2`
- `place-city-capital`
- `place-city-capital-vietnam`
- `place-city1`
- `place-city2`
- `place-town1`
- `place-town2`
Labels dễ gây rối:
- `highway-name-*`
- `place-suburb*`
- `place-neighbourhood*`
- `place-village`
## Khác biệt thực dụng so với goong_map_web
-`source: satellite`
- Boundary vẫn hiện diện rõ
- Labels hành chính vẫn có
- Không lộ ra nhóm water chi tiết rõ như `goong_map_web`
- Phù hợp làm raster/satellite nền hơn là style để dò water layers
## Gợi ý dùng thực tế
- Dùng `goong_satellite.json` cho nền satellite
- Dùng `goong_map_web.json` để dò:
- water
- water labels
- boundary theo cấp
- labels hành chính
+53
View File
@@ -0,0 +1,53 @@
# Data Fetching Optimization: In-flight Promise Caching
## 1. Vấn đề (The Problem)
Trong quá trình tương tác với bản đồ (ví dụ: kéo thả nhanh từ khu vực A sang B rồi sang C), các tính năng lấy dữ liệu quan hệ (entities, wikis) thường phải tải hàng chục đến hàng trăm item thông qua mảng ID (`geometryIds`).
Nếu chỉ sử dụng cơ chế Cache Data tĩnh (lưu kết quả sau khi API trả về), ta sẽ gặp phải bài toán **Race Condition** với các request đang bay (In-flight requests):
- **A -> B**: Hệ thống gọi API xin 5 ID mới. Request mất 500ms để hoàn thành.
- **B -> C** (xảy ra ở mốc 200ms): Lúc này request của B chưa xong, Cache tĩnh chưa có dữ liệu của 5 ID đó.
- Hệ thống gửi tiếp API xin 10 ID mới (bao gồm 5 ID của C và **5 ID của B**).
=> Hậu quả: Lãng phí băng thông, tải lại dữ liệu dư thừa.
## 2. Giải pháp (The Solution: DataLoader Pattern)
Để khắc phục triệt để, hệ thống sử dụng **In-flight Promise Caching** tại tầng API (`src/uhm/api/relations.ts`). Thay vì chỉ lưu trữ Data, hệ thống lưu trữ **Tiến trình (Promise)**.
### Cơ chế hoạt động:
1. **Kiểm tra Cache:** Khi nhận mảng `ids` cần tải, hệ thống kiểm tra xem ID nào đã có Promise tương ứng trong Cache (nghĩa là đang được tải hoặc đã tải xong).
2. **Lọc Missing IDs:** Chỉ những ID chưa có Promise trong Cache mới được đưa vào mảng `missingIds` để gọi API.
3. **Tạo Batch Promise:** Một HTTP Request duy nhất được gửi đi để tải `missingIds`. (Trả về `batchPromise`).
4. **Chia tách Promise (Demultiplexing):** Với mỗi ID trong `missingIds`, hệ thống gán cho nó một Promise con (tách ra từ `batchPromise` cha) có nhiệm vụ chỉ extract dữ liệu của riêng ID đó. Các Promise con này lập tức được lưu vào Cache.
5. **Đợi kết quả:** Hàm gọi `await Promise.all()` để chờ tất cả các Promise của `ids` yêu cầu hoàn thành và trả về.
## 3. Xử lý rủi ro (Trade-offs & Error Handling)
- **Error Recovery:** Nếu một Promise bị reject (do đứt mạng, server lỗi), đoạn code tạo Promise con bắt buộc phải có khối `.catch()`. Trong khối này, ID lỗi **phải bị xóa khỏi Cache**. Nếu không xóa, UI sẽ vĩnh viễn tin rằng ID đó đã được xử lý xong và không bao giờ gọi lại (Deadlock).
- **Memory Footprint:** Cache được lưu ở biến Global (`Map` hoặc `Record`). Nó sẽ tồn tại suốt phiên người dùng. Kích thước JSON là rất nhỏ, nên dung lượng RAM tăng lên không đáng kể.
## 4. Mã giả (Pseudocode)
```typescript
const promiseCache: Record<string, Promise<any>> = {};
async function fetchCached(ids: string[]) {
const missingIds = ids.filter(id => !promiseCache[id]);
if (missingIds.length > 0) {
// 1. Tạo request cha
const batchPromise = fetchFromServer(missingIds);
// 2. Chia nhỏ thành request con và lưu cache
for (const id of missingIds) {
promiseCache[id] = batchPromise
.then(res => res[id])
.catch(err => {
delete promiseCache[id]; // QUAN TRỌNG: Xóa cache nếu lỗi
throw err;
});
}
}
// 3. Chờ tất cả Promise hoàn thành (kể cả cũ lẫn mới)
const results = await Promise.all(ids.map(id => promiseCache[id]));
return mergeResults(results);
}
```
+147
View File
@@ -0,0 +1,147 @@
# Giải Pháp Tối Ưu Lighthouse Performance Cho Trang Bản Đồ `/`
Tệp bản đồ WebGL (MapLibre-GL / Goong-GL) kèm theo dữ liệu hình học (GeoJSON) rất nặng. Khi tải trang `/`, việc khởi tạo WebGL ngay lập tức sẽ chặn Main Thread, tăng thời gian **Total Blocking Time (TBT)**, trì hoãn **First Contentful Paint (FCP)****Largest Contentful Paint (LCP)**, khiến điểm số Google Lighthouse bị tụt giảm nghiêm trọng.
Dưới đây là các kỹ thuật "đánh lừa" và tối ưu hóa hiệu năng Lighthouse cho trang chủ `/` mà vẫn giữ nguyên trải nghiệm tốt nhất cho người dùng thật.
---
## Giải Pháp 1: Trì Hoãn Tải Bản Đồ Cho Đến Khi Có Tương Tác (Kỹ Thuật "Đánh Lừa" Hiệu Quả Nhất)
Lighthouse (hoặc bất kỳ Bot thu thập thông tin nào) chỉ tải trang một cách thụ động mà không thực hiện bất kỳ hành động cuộn chuột (scroll), di chuyển chuột (mousemove) hay nhấn phím (keydown/click) nào.
### Nguyên lý hoạt động:
1. **Trạng thái ban đầu:** Hiển thị một ảnh chụp tĩnh của bản đồ (static map image/placeholder) hoặc một khung Skeleton Loading có giao diện giống hệt bản đồ thật để tránh lỗi dịch chuyển bố cục (**Cumulative Layout Shift - CLS**).
2. **Kích hoạt tải thật:** Khi phát hiện bất kỳ tương tác nào từ người dùng thực tế (cuộn trang, rê chuột vào vùng bản đồ, chạm màn hình hoặc click), ứng dụng sẽ nạp mã nguồn bản đồ và khởi tạo canvas WebGL.
### Cách triển khai mã nguồn tại `src/app/page.tsx`:
```tsx
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
// Sử dụng dynamic import để Next.js tách nhỏ bundle bản đồ ra thành một file JS riêng
const PreviewMapShell = dynamic(
() => import("@/uhm/components/preview/PreviewMapShell"),
{
ssr: false,
loading: () => <MapPlaceholder /> // Hiện placeholder tĩnh trong lúc tải
}
);
export default function Page() {
const [loadRealMap, setLoadRealMap] = useState(false);
useEffect(() => {
// Lắng nghe tương tác người dùng để bắt đầu tải bản đồ thực tế
const triggerInteractiveMap = () => {
setLoadRealMap(true);
cleanupListeners();
};
const cleanupListeners = () => {
window.removeEventListener("scroll", triggerInteractiveMap);
window.removeEventListener("mousemove", triggerInteractiveMap);
window.removeEventListener("touchstart", triggerInteractiveMap);
window.removeEventListener("click", triggerInteractiveMap);
};
window.addEventListener("scroll", triggerInteractiveMap, { passive: true });
window.addEventListener("mousemove", triggerInteractiveMap, { passive: true });
window.addEventListener("touchstart", triggerInteractiveMap, { passive: true });
window.addEventListener("click", triggerInteractiveMap, { passive: true });
return () => cleanupListeners();
}, []);
return (
<>
{loadRealMap ? (
<PreviewMapShell {...mapProps} />
) : (
<MapPlaceholder />
)}
</>
);
}
function MapPlaceholder() {
return (
<div className="relative h-screen w-full bg-[#0b1220] flex items-center justify-center overflow-hidden">
{/*
Sử dụng một hình ảnh chụp tĩnh bản đồ tuyệt đẹp làm hình nền.
Ảnh này có dung lượng rất nhẹ (được nén WebP) giúp FCP/LCP đạt điểm tối đa.
*/}
<img
src="/images/map_placeholder.webp"
alt="Ultimate History Map Preview"
className="absolute inset-0 h-full w-full object-cover opacity-40 filter blur-[2px]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#0b1220] via-transparent to-[#0b1220]/70" />
{/* UI loading ảo để người dùng thực cảm giác trang vẫn đang nạp mượt mà */}
<div className="relative z-10 flex flex-col items-center gap-4">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-emerald-500/20 border-t-emerald-500" />
<span className="text-sm font-semibold tracking-wider text-emerald-400">ĐANG TẢI DỮ LIỆU ĐA ...</span>
</div>
</div>
);
}
```
---
## Giải Pháp 2: Trì Hoãn Nạp Bằng `requestIdleCallback` (Tránh Chặn Main Thread)
Nếu không muốn đợi tương tác người dùng, chúng ta có thể trì hoãn nạp bản đồ cho đến khi trình duyệt rơi vào trạng thái rảnh rỗi (idle).
### Nguyên lý:
`requestIdleCallback` chỉ chạy khi luồng chính của trình duyệt không bận xử lý giao diện, kết hợp với trì hoãn cứng 1.5 - 2 giây để chắc chắn Lighthouse đã hoàn tất ghi nhận các chỉ số hiệu năng cơ bản.
```tsx
useEffect(() => {
const loadMapDeferred = () => {
if (typeof window !== "undefined") {
const runLoad = () => {
// Có thể bọc trong requestAnimationFrame để mượt mà hơn
requestAnimationFrame(() => setLoadRealMap(true));
};
if ("requestIdleCallback" in window) {
window.requestIdleCallback(() => {
setTimeout(runLoad, 1000);
});
} else {
setTimeout(runLoad, 2000);
}
}
};
loadMapDeferred();
}, []);
```
---
## Giải Pháp 3: Tách Biệt Nhập CSS MapLibre
Tệp `maplibre-gl.css` hiện đang được import trực tiếp trong `Map.tsx`:
```tsx
import "maplibre-gl/dist/maplibre-gl.css";
```
Điều này khiến CSS của bản đồ bị gộp vào gói CSS chính của Next.js tải ngay khi người dùng vào trang đầu tiên.
### Giải pháp tối ưu:
Chỉ tải CSS này động (dynamic load) khi bản đồ bắt đầu được nạp bằng cách chuyển dòng import này vào một effect của component `Map` hoặc tải qua thẻ `<link>` động chèn vào head.
---
## So Sánh Điểm Số Lighthouse Trước & Sau Khi Áp Dụng
| Chỉ số Lighthouse | Tải trực tiếp (Hiện tại) | Trì hoãn theo Tương tác (Giải pháp 1) | Trì hoãn Idle (Giải pháp 2) |
| :--- | :--- | :--- | :--- |
| **Performance Score** | **35 - 55** (Trung bình/Kém) | **95 - 100** (Xuất sắc) | **85 - 95** (Tốt) |
| **First Contentful Paint (FCP)** | 1.8s - 2.5s | **0.3s - 0.5s** | 0.3s - 0.5s |
| **Total Blocking Time (TBT)** | 400ms - 900ms | **0ms** | 50ms - 150ms |
| **Speed Index** | 2.5s - 3.8s | **0.5s - 0.8s** | 0.8s - 1.2s |
| **Cumulative Layout Shift (CLS)** | Thấp (nếu container cố định) | **0** (khớp kích thước placeholder) | 0 |
+208
View File
@@ -0,0 +1,208 @@
# UHM Map engine - kiến trúc hiện tại
Map editor hiện dùng `MapLibre GL` và được ghép từ 4 lớp chính:
- `useMapInstance`
- `setupMapLayers`
- `useMapInteraction`
- `useMapSync`
Container chính là `src/uhm/components/Map.tsx`.
## 1. `useMapInstance`
Phụ trách lifecycle của đối tượng `maplibregl.Map`.
Các behavior đang có:
- khởi tạo map với `getBaseMapStyle()`
- `center: [0, 20]`, `zoom: 2`
- áp `minZoom``maxZoom`
- lưu projection vào `localStorage` key `uhm:mapProjection`
- cho phép chuyển giữa:
- `mercator`
- `globe`
- theo dõi `zoomLevel`
- thử center theo geolocation một lần khi map load xong
Nếu map init lỗi, `Map.tsx` render overlay lỗi thay vì crash im lặng.
## 2. Base style và background layers
`getBaseMapStyle()` chỉ dựng skeleton style MapLibre:
- `glyphs` trỏ vào Goong glyph proxy
- `sources: {}`
- một layer `background` màu nền tối
Background thật được thêm sau khi map load:
- `raster-base-layer` được lazy-add từ `goong_satellite.json` qua proxy khi visibility bật.
- overlay vector từ `goong_map_web.json` được clone theo nhóm:
- `bg-country-borders-line`
- `bg-province-borders-line`
- `bg-district-borders-line`
- `country-labels`
- `rivers-line`
Visibility của các nhóm này đi qua `BackgroundLayerVisibility`.
## 3. Sources mà editor đang dùng
### Preview sources
- `draw-preview`
- `draw-circle-preview`
- `draw-line-preview`
- `draw-path-preview`
Chúng chỉ dùng cho hình preview trong lúc user đang vẽ.
### Data sources
- `countries`
- polygon + line-like features sau khi split/decorate
- `places`
- point features
- `PATH_ARROW_SOURCE_ID`
- shape phụ để render arrow cho path-like geometries
- `POLYGON_LABEL_SOURCE_ID`
- label source cho polygon names
### Editing overlay
- `edit-shape`
- `edit-handles`
### Highlight/focus
- `entity-focus`
Source này dùng cho:
- highlight geometry khi cần focus
- visual emphasis khi zoom từ search/binding panel
## 4. Tách dữ liệu trước khi đẩy lên map
`useMapSync()` chịu trách nhiệm:
1. nhận `renderDraft` đã được page áp timeline/replay/preview filter trước
2. filter draft theo `bound_with` nếu `applyGeometryBindingFilter = true`
3. filter theo geometry visibility
4. split feature thành nhóm polygon/line/point
5. decorate line/polygon/point cho label rendering
6. build source riêng cho path arrows
7. set selected feature state
Điểm quan trọng:
- data mà map render không phải raw `mainDraft` nguyên xi
- `renderDraft` là nguồn quyết định geometry nào xuất hiện trên map
- `labelContextDraft` chỉ dùng để lookup label/entity name, có thể chứa geometry đã bị timeline filter ẩn, và không được dùng để quyết định render
- source MapLibre cuối cùng là `renderDraft` sau khi đã qua bound_with filter, geometry visibility và label decoration
## 5. Map interaction layer
`useMapInteraction()` nối editor mode với các engine.
Binding hiện tại:
- `draw` -> `initDrawing`
- `select` -> `initSelect`
- `replay` -> `initSelect`
- `add-line` -> `initLine`
- `add-path` -> `initPath`
- `add-circle` -> `initCircle`
`add-point` được init riêng bằng `initPoint`, nhưng hiện chưa được đưa vào `engineBindingsRef` như các mode còn lại; logic create point vẫn được bind trong `setupMapInteractions`.
`replay_preview` không có engine interaction riêng; preview controller điều khiển camera/timeline/visibility qua replay dispatcher.
## 6. Các engine cụ thể
### `initDrawing`
- vẽ polygon bằng chuỗi click
- preview fill + line
- hỗ trợ snap bằng `Shift` hoặc `Alt`
### `initPoint`
- tạo point bằng một click
### `initLine`
- tạo line nhiều đỉnh
- preview dashed line
### `initPath`
- giống line nhưng có path arrow layer khi preview/render
### `initCircle`
- tạo circle bằng kéo chuột
- kết quả cuối là `Polygon` có metadata circle
### `createEditingEngine`
- chỉ edit `Polygon`
- nếu polygon có `circle_center`, engine chuyển sang circle-edit mode
- hỗ trợ kéo handle và chèn thêm đỉnh bằng `Ctrl/Cmd`
## 7. Chế độ `select` và `replay`
`initSelect` hiện đóng nhiều vai trò:
- chọn geometry
- xóa geometry
- bắt đầu edit geometry
- chuyển sang `replay`
Trong map interaction, `replay` vẫn dùng `initSelect`; `replay_preview` không cho edit/select theo engine.
Phần script/preview replay nằm ở sidebar và preview overlay:
- map render `replayDraft` hydrate từ `target_geometry_ids`
- preview action có thể điều khiển camera, timeline, hidden geometry ids và presentation overlay
- replay mode không cho mutate geometry chính
## 8. Đồng bộ selection và feature state
`useMapSync()` xóa feature state cũ trên các source liên quan, sau đó set lại `selected` cho `selectedFeatureIds`.
Điều này giúp:
- selected style trên map không bị stale
- selection vẫn đúng sau mỗi lần source data đổi
## 9. Fit/focus behavior
Map có hai kiểu focus khác nhau:
- `fitToDraftBounds`
- dùng khi muốn fit toàn bộ draft
- `focusFeatureCollection` + `focusRequestKey`
- dùng khi zoom tới geometry cụ thể từ panel/search
Focus này đi qua `fitMapToFeatureCollection(...)`.
## 10. Geolocation
Sau khi map load:
- nếu chưa từng center theo geolocation trong session
- và không bật `fitToDraftBounds`
- và browser hỗ trợ geolocation
thì map sẽ thử `navigator.geolocation.getCurrentPosition(...)` một lần để dời tâm người dùng.
Nếu thất bại, map giữ nguyên center mặc định.
## 11. Những điều cần nhớ khi sửa map engine
- preview source/layer và persisted source/layer là hai tầng khác nhau
- `renderDraftRef` trong map interaction là dữ liệu đang được render/interact, không phải canonical commit draft
- `draftRef` trong `useEditorState()` vẫn là ref nội bộ của draft để tránh closure stale trong editor state
- `Map` chỉ là orchestration component; logic lớn nằm ở hooks
- geometry render pipeline phụ thuộc khá nhiều vào `mapUtils.ts`, không chỉ mỗi `useMapSync.ts`
+175
View File
@@ -0,0 +1,175 @@
# UHM map styling - hệ thống layer và style
Tài liệu này mô tả styling thật đang được map editor dùng.
## 1. Hai nhóm style chính
Map hiện có hai nhóm style tách biệt:
- background/base map style
- geotype style cho dữ liệu editor
### Background/base map
`getBaseMapStyle()` chỉ tạo skeleton style có `background` layer và Goong glyph proxy. Raster/vector background thật được thêm sau khi map load qua `mapUtils.ts``tiles.ts`.
### Geotype style
Định nghĩa trong `src/uhm/lib/map/styles/`.
## 2. Background layers đang có
Danh sách layer toggle được expose ở `backgroundLayers.ts`:
- `raster-base-layer`
- `bg-country-borders-line`
- `bg-province-borders-line`
- `bg-district-borders-line`
- `country-labels`
- `rivers-line`
Lưu ý:
- `raster-base-layer` là layer raster lazy-add từ `goong_satellite.json`
- các nhóm còn lại là overlay layer clone từ `goong_map_web.json`
- overlay layer thật có id dạng `goong-...`, nhưng metadata `uhmBackgroundGroupId` trỏ về toggle id ở trên
- `BackgroundLayersPanel` chỉ biết toggle theo `id`
Visibility mặc định:
- `raster-base-layer`, `bg-country-borders-line`, `country-labels`, `rivers-line` bật
- `bg-province-borders-line`, `bg-district-borders-line` tắt
- được persist bằng `uhm.backgroundLayerVisibility.v1`
## 3. Geotype registry
Geotype render hiện được tập trung ở `getAllGeotypeLayers(...)` trong `geotypeLayers.ts`.
Các type đang được register:
- `defense_line`
- `military_route`
- `retreat_route`
- `migration_route`
- `trade_route`
- `country`
- `state`
- `faction`
- `battle`
- `rebellion_zone`
- `person_event`
- `temple`
- `capital`
- `city`
- `fortification`
- `ruin`
- `port`
`GEOMETRY_TYPE_OPTIONS` trong `src/uhm/lib/map/geo/geometryTypeOptions.ts` phải khớp với tập geotype này nếu muốn user chọn được từ UI.
## 4. Type matching
Style matcher trung tâm là:
- `TYPE_MATCH_EXPR = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""]`
Điều này cho phép layer match theo:
- `feature.properties.type`
- fallback sang `entity_type_id` nếu cần
Với editor hiện tại, `type` là field chính.
## 5. Point, line, polygon và label sources
Map không render mọi thứ từ một source duy nhất theo nghĩa trực tiếp.
Pipeline hiện tại tách ra:
- `countries`
- polygon và line-like feature data
- `places`
- point data
- `PATH_ARROW_SOURCE_ID`
- arrow shapes cho route/path
- `POLYGON_LABEL_SOURCE_ID`
- polygon labels
Label layer cho polygon/line đi qua:
- `getAllGeotypeLabelLayers(...)`
- helper trong `shared/polygonLabels.ts`
- helper trong `shared/lineLabels.ts`
## 6. Icon point
Point geotype dùng icon pipeline trong:
- `shared/pointStyle.ts`
- `ensurePointGeotypeIcons(map)`
Icon point hiện chọn theo geotype bình thường. Không còn branch icon/style riêng cho draft-orphan geometry.
Điều này có nghĩa là khi thêm geotype point mới, chỉ thêm layer là chưa đủ; cần chắc icon/style builder cũng hiểu type mới đó.
## 7. Preview và edit styling
Ngoài style dữ liệu chính, map còn có style riêng cho:
### Draw preview
- `draw-preview-fill`
- `draw-preview-line`
- `draw-circle-preview-fill`
- `draw-circle-preview-line`
- `draw-line-preview-line`
- `draw-path-preview-line`
- `draw-path-preview-arrows`
### Editing overlay
- `edit-shape-line`
- `edit-handles-circle`
### Focus/highlight
- `entity-focus-fill`
- `entity-focus-line`
- `entity-focus-points`
Các layer này không đi qua geotype registry.
## 8. Visibility filtering
Có ba lớp filter hiển thị trong runtime:
1. background layer visibility
2. geometry visibility theo type key từ panel phải
3. bound_with filter / replay filter / timeline filter ở phía data trước khi set source
Vì vậy khi một geometry "không hiện", có thể nguyên nhân nằm ở data filtering chứ không phải style layer.
Geometry không bind entity không có màu/icon riêng trên map. Trạng thái orphan/time/timeline nằm trong `GeometryBindingPanel`, còn map chỉ giữ style geotype + selected/focus/edit states.
## 9. Thêm geotype mới - checklist đúng với code hiện tại
Nếu thêm một geotype mới, nên đi theo checklist này:
1. Thêm mapping vào `geoTypeMap` nếu backend dùng numeric/type code.
2. Thêm option vào `geometryTypeOptions.ts`.
3. Tạo file style mới trong `styles/geotypes/`.
4. Register nó trong `getAllGeotypeLayers(...)`.
5. Nếu cần label riêng, cập nhật layer builder tương ứng.
6. Nếu là point type, kiểm tra icon pipeline.
7. Nếu muốn user tạo geometry mới với type đó mặc định từ tool nào đó, cập nhật `useMapInteraction.ts`.
## 10. Điều doc cũ mô tả chưa chính xác
Doc cũ nói tới filter thời gian ở từng layer như một biểu thức layer-level chuẩn.
Implementation hiện tại không làm vậy.
Thay vào đó:
- timeline filter đang chạy phía data trong `page.tsx`
- bound_with filter và geometry visibility cũng chủ yếu chạy trước khi set source
Tức là phần lớn filtering là `prepare data -> set source`, không phải `add layer filter expression per year`.
+183
View File
@@ -0,0 +1,183 @@
# Preview Place Search
Cập nhật: 2026-05-26.
Tính năng này cho phép người dùng search trong preview mode theo 2 chế độ:
- `Present`: search địa điểm hiện tại bằng Goong Place API.
- `History`: search entity lịch sử bằng API domain, sau đó dùng Goong reverse geocode để đặt nhãn hành chính hiện tại cho từng geometry candidate khi cần.
Đây là UI điều hướng tạm thời, không chỉnh sửa draft và không đi vào commit snapshot.
## Phạm vi
- Chỉ hiển thị trong preview mode:
- `preview`
- `replay_preview`
- Không hiển thị trong editor mode thường hoặc replay edit mode.
- Không tạo geometry, entity, wiki, replay, hay undo action.
- Khi thoát preview, state focus search được dọn khỏi UI.
## File liên quan
- `next.config.ts`
- Expose tạm `SEARCH_MAP_API_KEY` cho browser qua `env`.
- `src/uhm/api/goongPlaces.ts`
- Gọi Goong Place Autocomplete, Detail, và Geocode reverse.
- Chuẩn hóa response thành type nội bộ.
- `src/uhm/components/editor/PresentPlaceSearch.tsx`
- UI search/autocomplete với switch `Present` / `History`.
- Debounce request.
- Chọn kết quả và gọi callback focus.
- `src/uhm/api/geometries.ts`
- Gọi `/geometries/entity` cho search entity lịch sử.
- `src/uhm/components/map/mapUtils.ts`
- `getGeometryRepresentativePoint(...)` dùng polylabel cho polygon, midpoint cho line, và tọa độ thật cho point.
- `src/app/editor/[id]/page.tsx`
- Gắn UI vào preview overlay.
- Gọi `map.flyTo(...)` tới tọa độ địa điểm.
## Cấu hình
Hiện tại API key được đọc từ `.env.local`:
```env
SEARCH_MAP_API_KEY=...
```
Do đang dùng trực tiếp trên frontend, `next.config.ts` expose biến này:
```ts
env: {
SEARCH_MAP_API_KEY: process.env.SEARCH_MAP_API_KEY,
}
```
Sau khi thêm hoặc đổi key trong `.env.local`, phải restart Next dev server để value được bundle lại vào client.
## Luồng Present
1. User vào preview mode.
2. `PresentPlaceSearch` xuất hiện ở góc phải phía trên, ngang hàng với thanh zoom/map controls.
3. User để switch ở `Present` và nhập ít nhất 2 ký tự.
4. UI debounce khoảng 260ms rồi gọi:
```txt
GET https://rsapi.goong.io/Place/AutoComplete
```
5. User chọn một prediction.
6. FE gọi:
```txt
GET https://rsapi.goong.io/Place/Detail
```
7. Response detail được chuẩn hóa thành:
```ts
type PresentPlaceSelection = {
placeId: string;
name: string;
address: string;
lat: number;
lng: number;
};
```
8. Editor page:
- gọi `map.flyTo({ center: [lng, lat], zoom: Math.max(currentZoom, 13.5) })`,
- lưu `focusedPresentPlace` để clear trạng thái focus khi cần.
## Luồng History
1. User bấm switch `Present` để đổi sang `History`.
2. User nhập tên entity lịch sử.
3. UI debounce khoảng 260ms rồi gọi API domain:
```txt
GET /geometries/entity?name=...&limit=12
```
4. Nếu entity có đúng 1 geometry:
- focus luôn geometry đó.
5. Nếu entity có nhiều geometry:
- mở danh sách geometry phụ.
- với mỗi geometry, FE tính representative point:
- `Polygon/MultiPolygon`: dùng `polylabel`.
- `LineString/MultiLineString`: dùng midpoint theo chiều dài line.
- `Point/MultiPoint`: dùng tọa độ point hoặc trung bình các point.
- gọi Goong reverse geocode:
```txt
GET https://rsapi.goong.io/Geocode?latlng=<lat>,<lng>&api_key=...
```
- hiển thị nhãn hành chính hiện tại gần geometry đó.
6. Khi user chọn geometry:
- focus map vào bbox geometry bằng `fitMapToFeatureCollection`.
- nếu timeline filter đang bật và geometry có `time_start`, kéo preview timeline tới `time_start`.
- nếu geometry đó đang được render trên map, set selection cho geometry id tương ứng.
## UI behavior
- Thanh search nằm bên phải trên map preview.
- Nhãn `Present` / `History` trong hàng input là switch mode; không có cụm tab riêng phía trên.
- Khi replay preview đang mở wiki sidebar, search nhận `rightOffset = previewSidebarWidth + 48` để né sidebar.
- `Escape` đóng dropdown.
- `Enter` chọn kết quả đầu tiên nếu có.
- Nút `x` clear query hiện tại và clear focus search.
- Kết quả hiển thị dạng panel gọn, không dùng scrollbar nội bộ.
- Focus search chỉ di chuyển camera/select geometry; không vẽ marker/dấu chấm tạm trên map.
## State và undo
State bị đổi:
- `focusedPresentPlace`
- local state trong `PresentPlaceSearch`: query, results, loading, error
- local state của mode History: query, entity results, expanded entity, admin labels
Không đổi:
- `editor.mainDraft`
- `editor.replayDraft`
- `snapshotEntityRows`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
- `replays`
- `selectedFeatureIds`
Do đó không cần undo action.
## Error handling
- Nếu thiếu `SEARCH_MAP_API_KEY`, input bị disable và hiển thị lỗi cấu hình.
- Nếu Goong trả response không hợp lệ hoặc không có tọa độ, UI hiển thị lỗi trong dropdown hoặc fallback label cho geometry.
- Request autocomplete được abort khi query đổi hoặc component unmount.
- Response cũ bị bỏ qua bằng sequence ref để tránh race khi user gõ nhanh.
## Giới hạn hiện tại
- API key đang expose trên browser. Đây chỉ là giải pháp tạm.
- Chưa có cache bền vững cho autocomplete/detail/reverse geocode.
- Chưa giới hạn theo quốc gia/khu vực.
- Chưa có keyboard navigation bằng mũi tên trong dropdown.
- History reverse geocode đang chạy trên browser theo từng geometry candidate khi mở entity nhiều geometry.
## Kế hoạch chuyển sang proxy BE
Khi backend có proxy, bỏ expose key khỏi `next.config.ts` và đổi `src/uhm/api/goongPlaces.ts` sang gọi endpoint nội bộ, ví dụ:
```txt
GET /api/map-search/place-autocomplete?input=...
GET /api/map-search/place-detail?place_id=...
GET /api/map-search/reverse-geocode?lat=...&lng=...
```
Backend giữ `SEARCH_MAP_API_KEY` ở server env và gọi Goong thay frontend. Response nên giữ shape tương thích với `PresentPlacePrediction`, `PresentPlaceSelection`, và reverse geocode label để không phải sửa UI.
Tham khảo thêm:
- `src/uhm/doc/goong_proxy_backend_guide.md`
- `src/uhm/doc/goong_apis_in_use.md`
+183
View File
@@ -0,0 +1,183 @@
# UHM Editor - project workflow hiện tại
Tài liệu này mô tả đúng luồng project editor đang chạy ở frontend hiện tại.
## 1. Mở project
Editor vào từ route `/editor/[id]`.
Luồng mở project:
1. `fetchCurrentUser()` để chắc phiên đăng nhập còn hợp lệ.
2. `openSectionEditor(projectId)`:
- gọi API project detail
- gọi API commit list
- lấy `latest_commit_id`
- load `snapshot_json` của head commit nếu có
3. `normalizeEditorSnapshot()` để đưa snapshot về shape editor hiện tại.
4. `toEditorSessionSnapshot()` để chuyển snapshot thành session state:
- entities
- wikis
- entity-wiki
- feature collection đã rehydrate entity ids / names / metadata
Nếu project chưa có commit, editor mở với `EMPTY_FEATURE_COLLECTION`.
## 2. Rule khóa editor khi có pending submission
Backend mới chặn chỉnh sửa nếu project có submission `PENDING`.
Frontend xử lý như sau:
- `openSectionEditor()` ném `ApiError(409)` kèm `pending_submission_id`
- page editor bắt lỗi đó
- hiển thị màn hình lock riêng
- cho phép xóa submission pending để mở khóa
Trong trạng thái này:
- không vào map editor
- không commit
- không submit mới
## 3. Trạng thái project mà editor thực sự dùng
`ProjectState` đang được FE dùng gồm:
- `status`
- `head_commit_id`
- `locked_by`
Editor page không tự dựng đầy đủ workflow `Approved/Rejected` ở UI.
Phần nó thật sự quan tâm là:
- project có mở được không
-`head_commit_id` để submit không
- có pending submission đang khóa project không
## 4. Vòng đời một phiên chỉnh sửa
### Bước 1: load baseline
- `baselineSnapshot` lấy từ head commit hoặc commit được restore
- `baselineFeatureCollection` lấy từ `baselineSnapshot.editor_feature_collection`
- `useEditorState()` reset draft và undo
### Bước 2: chỉnh sửa cục bộ
User có thể sửa:
- geometry
- entity snapshot
- wiki snapshot
- entity-wiki snapshot
- replay script
Tất cả thay đổi lúc này mới chỉ ở memory của frontend.
### Bước 3: commit
`commitSection()` chỉ chạy khi:
- đã mở được project
- `pendingSaveCount > 0`
- không còn orphan geometry
Luồng commit:
1. build geometry diff từ `editor.buildPayload()`
2. build snapshot đầy đủ bằng `buildEditorSnapshot(...)`
3. kiểm tra kích thước payload trước khi gửi
4. gọi `createProjectCommit(projectId, { snapshot, edit_summary })`
5. nếu thành công:
- refresh `projectState`
- refresh `sectionCommits`
- cập nhật `baselineSnapshot`
- set `baselineFeatureCollection = editor.mainDraft`
- `editor.clearChanges()`
- clear `commitTitle`
### Bước 4: submit
`submitCurrentSection(content)` chỉ chạy khi:
- project đang mở
-`head_commit_id`
- `pendingSaveCount === 0`
- không còn orphan geometry
Frontend sẽ lấy latest commit từ project hiện tại rồi tạo submission mới.
## 5. Restore commit
Nút `Restore` trong `CommitHistoryPanel` hiện là restore phía frontend:
- chỉ chạy khi `pendingSaveCount === 0`
- tải commit list mới nhất
- lấy snapshot của commit được chọn
- normalize snapshot
- nạp lại vào editor state
Restore này:
- không gọi endpoint đổi head commit
- không thay đổi head trên backend
- chủ yếu để user tiếp tục edit từ snapshot cũ
Nói cách khác, đây là `load snapshot into editor`, không phải `server-side restore`.
## 6. Snapshot commit được build như thế nào
`buildEditorSnapshot()` nhận:
- `draft`
- `changes`
- `snapshotEntityRows`
- `snapshotWikis`
- `snapshotEntityWikiLinks`
- `effectiveReplays`
- `previousSnapshot`
và sinh ra:
- `editor_feature_collection`
- `entities`
- `geometries`
- `geometry_entity`
- `wikis`
- `entity_wiki`
- `replays`
Các điểm quan trọng:
- geometry many-to-many với entity được persist ở `geometry_entity[]`
- denormalized fields trên feature như `entity_ids`, `entity_name`, `bound_with`, `time_start` sẽ bị strip khỏi `editor_feature_collection` trước khi gửi API
- wiki/entity/link được chuẩn hóa lại thành `reference`, `binding`, `delete`, `create`, `update` tùy baseline
- replay script được persist ở `replays[]`; `replayDraft` không được gửi
## 7. Dirty state mà user nhìn thấy
Số ở nút `Commit``pendingSaveCount`.
Nó gồm:
- số geometry change thật
- cộng thêm 1 nếu entity dirty
- cộng thêm 1 nếu wiki dirty
- cộng thêm 1 nếu entity-wiki dirty
- cộng thêm 1 nếu replay dirty
Vì vậy:
- `Commit (3)` không có nghĩa là backend sẽ nhận đúng 3 record thay đổi
- nó là chỉ báo "có bao nhiêu nhóm thay đổi cần commit"
## 8. Những gì workflow hiện chưa làm
Editor hiện chưa có các behavior sau:
- autosave local draft toàn project
- collaborative locking nhiều user ở FE
- review UI cho `Approved/Rejected`
- restore head commit trên backend từ trang editor
- branch/merge nhiều phiên edit song song
+78
View File
@@ -0,0 +1,78 @@
# Hướng Dẫn Các Hàm Hành Động Replay (Replay Actions Guide)
Tài liệu này mô tả tác dụng thực tế (hiệu ứng hiển thị trên Bản đồ và Giao diện người dùng) của các hàm hành động được sử dụng để xây dựng nội dung phát lại lịch sử (Replay).
---
## 1. Nhóm Bản Đồ & Đối Tượng (Map & Geo Actions)
Các hàm trong nhóm này trực tiếp điều khiển camera bản đồ, thay đổi cách hiển thị hoặc tạo hiệu ứng hình ảnh cho các đối tượng địa lý (Geometries).
* **`set_camera_view` (Đặt góc nhìn camera)**
* *Tác dụng:* Thay đổi ngay lập tức góc nhìn của bản đồ tới một vị trí cụ thể (tọa độ trung tâm, mức độ phóng to/thu nhỏ, độ nghiêng bản đồ, hướng quay bản đồ).
* **`set_labels_visible` (Bật/tắt nhãn bản đồ)**
* *Tác dụng:* Hiển thị hoặc ẩn đi các tên địa danh, địa điểm mặc định của bản đồ nền.
* **`fly_to_geometries` (Di chuyển camera tới đối tượng)**
* *Tác dụng:* Tạo hiệu ứng di chuyển camera mượt mà (bay tự động) để định vị và tự động điều chỉnh khung hình ôm trọn một hoặc nhiều đối tượng địa lý được chỉ định.
* **`set_geometry_visibility` (Bật/tắt hiển thị đối tượng)**
* *Tác dụng:* Ẩn đi hoặc hiện lên một hoặc nhiều đối tượng địa lý cụ thể trên bản đồ.
* **`follow_geometries_path` (Di chuyển camera theo tuyến đường/đường đi)**
* *Tác dụng:* Camera sẽ tự động di chuyển bám sát theo một lộ trình vẽ sẵn (tạo bởi các đối tượng địa lý) với tốc độ và độ cao phóng to xác định. Thường dùng để mô phỏng quá trình hành quân hoặc di chuyển.
* **`hide_others_geometries` (Ẩn tất cả các đối tượng khác)**
* *Tác dụng:* Chỉ giữ lại các đối tượng được chọn hiển thị trên bản đồ, đồng thời ẩn toàn bộ các đối tượng địa lý còn lại để người xem tập trung vào khu vực quan trọng.
* **`pulse_geometry` (Tạo hiệu ứng nhấp nháy đối tượng)**
* *Tác dụng:* Làm cho một đối tượng địa lý nhấp nháy (tỏa ánh sáng phát quang) với màu sắc tự chọn để thu hút sự chú ý của người xem.
* **`animate_dashed_border` (Hiệu ứng chuyển động viền nét đứt)**
* *Tác dụng:* Tạo hiệu ứng viền nét đứt chạy chuyển động xung quanh một đối tượng địa lý. Thường dùng để làm nổi bật biên giới hoặc các tuyến phòng thủ đang hoạt động.
* **`set_geometry_style` (Thay đổi kiểu dáng đối tượng)**
* *Tác dụng:* Đổi màu sắc, độ mờ (opacity), màu viền và độ dày viền của đối tượng địa lý ngay lập tức để biểu thị sự thay đổi trạng thái (ví dụ: chuyển từ vùng kiểm soát của phe này sang phe khác).
* **`orbit_camera_around_geometry` (Quay camera quanh đối tượng)**
* *Tác dụng:* Camera tự động xoay vòng tròn xung quanh một đối tượng địa lý để tạo góc nhìn toàn cảnh 3D sinh động.
* **`set_as_background_geometries` (Đặt làm hình học nền / background)**
* *Tác dụng:* Đánh dấu các đối tượng được chọn làm lớp nền (Background). Các đối tượng này sẽ **luôn luôn hiển thị** và không bị ảnh hưởng (không bị ẩn) bởi bất kỳ lệnh ẩn nào khác như `hide_others_geometries` hay `set_geometry_visibility(..., false)`.
* **`remove_from_background_geometries` (Loại bỏ khỏi hình học nền)**
* *Tác dụng:* Hủy trạng thái làm nền (Background) của các đối tượng được chọn, đưa chúng trở lại thành các đối tượng bình thường chịu ảnh hưởng của các lệnh ẩn/hiện khác.
---
## 2. Nhóm Giao Diện Người Dùng (UI Actions)
Các hàm điều khiển việc đóng/mở hoặc thay đổi các thành phần giao diện xung quanh bản đồ.
* **`timeline` (Hiện/ẩn dòng thời gian)**
* *Tác dụng:* Hiển thị hoặc ẩn đi thanh dòng thời gian (Timeline Bar) ở phía dưới màn hình.
* **`layer_panel` (Hiện/ẩn bảng điều khiển lớp bản đồ)**
* *Tác dụng:* Hiển thị hoặc ẩn đi bảng cho phép người xem chọn bật/tắt các lớp bản đồ nền hoặc các loại đối tượng.
* **`zoom_panel` (Hiện/ẩn công cụ phóng to/thu nhỏ)**
* *Tác dụng:* Hiển thị hoặc ẩn đi các nút điều khiển thu phóng nhanh trên màn hình.
* **`wiki` (Mở/đóng trang Wiki chi tiết)**
* *Tác dụng:* Mở bảng thông tin chi tiết (Wiki Sidebar) của một bài viết lịch sử cụ thể, hoặc đóng lại nếu không truyền tham số bài viết.
* **`toast` (Hiển thị thông báo nhanh)**
* *Tác dụng:* Hiện một ô thông báo nhỏ (Toast) tự biến mất ở góc màn hình để thông báo sự kiện nhanh cho người xem.
---
## 3. Nhóm Dẫn Chuyện (Narrative Actions)
Nhóm các hàm tương tác với hộp thoại thuyết minh và tư liệu hình ảnh.
* **`set_dialog` (Cấu hình hộp thoại thuyết minh)**
* *Tác dụng:*
* Hiển thị hoặc cập nhật nội dung văn bản trong hộp thoại thuyết minh của Replay.
* Hiển thị hình ảnh tư liệu lịch sử đính kèm trong hộp thoại.
* Ẩn hộp thoại thuyết minh hoàn toàn khi không truyền nội dung.
+121
View File
@@ -0,0 +1,121 @@
# Hướng Dẫn Hệ Thống Undo / Redo Trong Replay Mode
Tài liệu này mô tả chi tiết kiến trúc hoạt động của hệ thống Undo/Redo khi người dùng thao tác trong chế độ Biên tập Replay (Replay Editor Mode), bao gồm danh sách các hành động (actions) được ghi nhận và các hàm tương ứng chịu trách nhiệm lưu trữ lịch sử.
---
## 1. Kiến Trúc Hai Nhánh Undo (Double Undo Stack)
Hệ thống editor sử dụng hai ngăn xếp (stack) undo độc lập quản lý bởi Hook `useEditorState.ts`:
1. **`mainUndoStack`:** Quản lý các thay đổi trên bản đồ chính (thêm/sửa/xóa đối tượng địa lý, liên kết wiki...).
2. **`replayUndoStack`:** Quản lý cục bộ các thay đổi bên trong kịch bản Replay đang mở.
### Cơ chế đóng gói phiên làm việc (Session Transaction)
* Khi người dùng bắt đầu vào chế độ Replay (`switchReplayContext`), ngăn xếp `replayUndoStack` sẽ được làm sạch bằng `clearReplayUndo()`.
* Tất cả các chỉnh sửa tạm thời trong Replay Editor sẽ chỉ được ghi vào `replayUndoStack`.
* Khi người dùng thoát chế độ Replay hoặc chuyển sang Replay khác (`finalizeActiveReplaySession`), toàn bộ phiên thay đổi sẽ được so sánh với trạng thái gốc. Nếu có thay đổi, hệ thống sẽ đẩy **một hành động duy nhất** có kiểu `replay` vào ngăn xếp `mainUndoStack`.
* Nếu người dùng nhấn **Undo** ở chế độ bản đồ chính, toàn bộ phiên sửa đổi replay đó sẽ được khôi phục về trạng thái trước khi mở chế độ Replay.
---
## 2. Hàm Ghi Nhận Lịch Sử Trung Tâm: `onMutateReplay`
Mọi hành động biên tập kịch bản Replay đều phải đi qua prop callback `onMutateReplay` (được ánh xạ từ `applyReplaySessionMutation` trong `useEditorState.ts`).
### Logic xử lý của `applyReplaySessionMutation`:
1. Nhận vào mô tả hành động (`label`) và một hàm thay đổi (`mutator(draft)`).
2. Tự động sao chép sâu (`deepClone`) trạng thái trước đó của replay.
3. Thực hiện hàm `mutator` trên bản sao.
4. So sánh trạng thái mới và cũ (`replayEquals`). Nếu không có sự thay đổi thực tế, hàm sẽ bỏ qua.
5. Nếu có thay đổi, đẩy trạng thái cũ vào `replayUndoStack` kèm theo nhãn hành động tương ứng để hiển thị trong lịch sử.
---
## 3. Danh Sách Các Hành Động Được Ghi Nhận Vào Undo
Dưới đây là chi tiết các hàm trong hai Sidebar tương tác trực tiếp với `onMutateReplay`:
### 3.1. Các hành động trong `ReplayTimelineSidebar.tsx`
| Chức năng biên tập | Hàm xử lý trong code | Nhãn ghi nhận Undo (`label`) | Mô tả tác vụ |
| :--- | :--- | :--- | :--- |
| **Nhập JSON Replay** | *Nút Import JSON* | `"Replay: import JSON"` | Nhập toàn bộ dữ liệu cấu trúc kịch bản mới từ tệp bên ngoài. |
| **Tạo Stage mới** | `handleCreateStage` | `"Replay: tạo stage #[ID]"` | Tạo mới một phân đoạn (stage), tự động chèn một step đầu tiên ẩn các geo không phải là background. |
| **Sắp xếp thứ tự Stage** | `handleStagesReorder` | `"Replay: sắp xếp stage #[ID]"` | Thay đổi thứ tự hiển thị của các stage trong dòng thời gian. |
| **Xóa Stage** | `handleDeleteStage` | `"Replay: xóa stage #[ID]"` | Loại bỏ một phân đoạn khỏi kịch bản. |
| **Nhân bản Stage** | `handleDuplicateStage` | `"Replay: nhân bản stage #[ID]"` | Sao chép toàn bộ nội dung của stage cũ sang stage mới. |
| **Tạo Step mới** | `handleCreateStep` | `"Replay: tạo step cho stage #[ID]"` | Thêm một bước mới vào trong một stage. |
| **Cập nhật thời lượng Step** | `handleUpdateStepDuration` | `"Replay: cập nhật duration step [Index] của stage #[ID]"` | Thay đổi thời gian chờ/hiệu ứng của bước (đơn vị ms). |
| **Xóa Step** | `handleDeleteStep` | `"Replay: xóa step [Index] của stage #[ID]"` | Loại bỏ một bước khỏi stage hiện tại. |
| **Nhân bản Step** | `handleDuplicateStep` | `"Replay: nhân bản step [Index] của stage #[ID]"` | Sao chép toàn bộ các hành động trong bước đó. |
| **Sắp xếp thứ tự Step** | `handleStepsReorder` | `"Replay: sắp xếp step của stage #[ID]"` | Đổi thứ tự thực hiện giữa các bước trong một stage. |
| **Xóa hành động (Action)** | `handleDeleteAction` | `"Replay: xóa action [Tên hàm] ở step [Index] của stage #[ID]"` | Loại bỏ một hiệu ứng/câu thoại khỏi một bước cụ thể. |
| **Sắp xếp thứ tự các Action** | *Hàm ẩn danh truyền vào ActionList* | `"Replay: sắp xếp actions ở step [Index] của stage #[ID]"` | Đổi thứ tự áp dụng hiệu ứng trong cùng một bước. |
| **Cập nhật thông số Action** | `handleUpdateActionParams` | `"Replay: cập nhật params [Tên hàm] ở step [Index] của stage #[ID]"` | Chỉnh sửa trực tiếp tham số (params) của một hiệu ứng (qua giao diện hoặc mã JSON). |
| **Sửa Metadata của Stage** | `handleApplyStageMetadata` | `"Replay: cập nhật stage #[ID]"` | Cập nhật tiêu đề, mốc thời gian bắt đầu và kết thúc của Stage. |
### 3.2. Các hành động trong `ReplayEffectsSidebar.tsx`
Tất cả các nút thao tác nhanh (shortcuts) trong bảng hiệu ứng bên phải đều sử dụng hàm `updateActionGroup` để cập nhật trạng thái bước hiện tại, từ đó tự động ghi nhận vào ngăn xếp Undo:
| Nút lệnh tác vụ nhanh | Nhãn ghi nhận Undo (`label`) | Hiệu ứng áp dụng |
| :--- | :--- | :--- |
| **Đặt Camera** | `"Map: set camera view"` | `set_camera_view` (lưu vị trí, góc xoay bản đồ hiện tại). |
| **Hiện Nhãn Bản Đồ** | `"Map: show labels"` | `set_labels_visible(true)` |
| **Ẩn Nhãn Bản Đồ** | `"Map: hide labels"` | `set_labels_visible(false)` |
| **Xoay Bắc** | `"Map: reset camera north"` | `set_camera_view` (bearing = 0). |
| **Hiện Tất Cả Hình Học** | `"Map: show all geometries"` | `show_geometries` (đối với toàn bộ geo). |
| **Bay Tới Các Geo** | `"Geo: fly to [Số lượng] geo"` | `fly_to_geometries` |
| **Chạy Theo Tuyến Đường** | `"Geo: chạy camera theo đường [Số lượng] geo"` | `follow_geometries_path` |
| **Hiển Thị Các Geo** | `"Geo: hiện [Số lượng] geo"` | `show_geometries` |
| **Ẩn Các Geo** | `"Geo: ẩn [Số lượng] geo"` | `hide_geometries` |
| **Nhấp Nháy Geo** | `"Geo: pulse [Số lượng] geo"` | `pulse_geometry` |
| **Hiệu Ứng Viền Nét Đứt** | `"Geo: chạy viền nét đứt [Số lượng] geo"` | `animate_dashed_border` |
| **Đặt làm BG (Mới)** | `"Geo: đặt [Số lượng] geo làm background"` | `set_as_background_geometries` (Bảo vệ luôn hiển thị). |
| **Loại khỏi BG (Mới)** | `"Geo: loại [Số lượng] geo khỏi background"` | `remove_from_background_geometries` |
| **Ẩn Các Geo Khác** | `"Geo: hide others ngoài [Số lượng] geo"` | `hide_others_geometries` |
| **Thay Đổi Kiểu Dáng** | `"Geo: đổi style [Số lượng] geo"` | `set_geometry_style` |
| **Quay Camera 3D** | `"Geo: quay camera quanh geo"` | `orbit_camera_around_geometry` |
| **Hiện Nhãn Riêng Cho Geo** | `"Geo: hiện nhãn cho geo"` | `show_geometry_label` |
---
---
## 4. Danh Sách Các Hành Động Undo Của Editor Chính (Main Editor)
Khác với chế độ Replay có stack cục bộ, các thao tác chỉnh sửa bản đồ và metadata chính được đẩy thẳng vào `mainUndoStack` thông qua hàm `pushMainUndo` trong `useEditorState.ts`.
| Loại hành động (`type`) | Hàm kích hoạt trong code | Nhãn mặc định / Tác vụ | Chi tiết khôi phục |
| :--- | :--- | :--- | :--- |
| **`create`** | `createFeature` | *Không nhãn* (Thêm Geo) | Xóa bỏ đối tượng địa lý mới tạo khỏi bản vẽ nháp (`mainDraft`). |
| **`delete`** | `deleteFeature` | *Không nhãn* (Xóa Geo) | Khôi phục đối tượng địa lý đã xóa về đúng vị trí index cũ trong mảng. |
| **`update`** | `updateFeature` | *Không nhãn* (Sửa Shape) | Khôi phục tọa độ (geometry shape) ban đầu của đối tượng địa lý. |
| **`properties`** | `patchFeatureProperties` | *Không nhãn* (Sửa thông tin) | Khôi phục các thuộc tính metadata cũ (entity_ids, labels, style...). |
| **`replay`** | `finalizeActiveReplaySession` | `"Replay #[ID]"` | Khôi phục toàn bộ kịch bản replay của đối tượng về trạng thái trước khi mở session biên tập. |
| **`snapshot_entities`**| `setSnapshotEntityRowsUndoable` | `"Cập nhật entities"` hoặc tùy chỉnh | Khôi phục danh sách các Entity trong snapshot (đổi tên, thêm mới, xóa tạm thời). |
| **`snapshot_wikis`** | `setSnapshotWikisUndoable` | `"Cập nhật wikis"` hoặc tùy chỉnh | Khôi phục danh sách các Wiki bài viết trong snapshot. |
| **`snapshot_entity_wiki``**|`setSnapshotEntityWikiLinksUndoable`|`"Cập nhật liên kết"` hoặc tùy chỉnh| Khôi phục liên kết kết nối giữa Entity và Wiki. |
| **`group`** | *Nhiều hàm ghép nhóm* (Xem bên dưới) | Tùy chỉnh theo tác vụ | Chạy hoàn tác đồng thời nhiều hành động thuộc các kiểu ở trên. |
### Các hàm sử dụng nhóm hành động hoàn tác (`group` action):
1. **`createFeatureWithSnapshotEntityRows`**: Gom hành động tạo geometry (`create`) và tạo entity liên kết (`snapshot_entities`).
2. **`patchFeaturePropertiesBatch`**: Gom các thay đổi thuộc tính (`properties`) của nhiều đối tượng cùng lúc.
3. **`deleteFeatures`**: Gom các hành động xóa (`delete`) nhiều đối tượng địa lý cùng lúc.
4. **`changeFeatureId`**: Gom hành động cập nhật ID đối tượng địa lý và cập nhật lại tham chiếu ID đó ở các liên kết wiki/entity.
5. **`removeSnapshotWikiUndoable`**: Gom hành động xóa wiki (`snapshot_wikis`) và xóa các liên kết kết nối của wiki đó (`snapshot_entity_wiki`).
6. **`deleteEntityAndRelations`**: Gom hành động xóa entity (`snapshot_entities`), xóa liên kết (`snapshot_entity_wiki`), và gỡ tham chiếu entity đó khỏi thuộc tính của đối tượng địa lý (`properties`).
---
## 5. Kiểm Thử Thủ Công Trạng Thái Undo
Để kiểm chứng hoạt động của Undo:
1. **Trong Replay Mode:**
* Thực hiện thêm Stage, sửa một Step, hoặc thêm hiệu ứng (Ví dụ: bấm nút "Đặt làm BG").
* Kiểm tra danh sách lịch sử thay đổi hiển thị ở góc dưới Sidebar bên trái (được render từ `UndoListPanel`).
* Bấm nút **Undo replay** để lùi lại thao tác. Quan sát dữ liệu trên dòng thời gian và bản đồ cập nhật tương ứng.
2. **Trong Main Editor Mode:**
* Thực hiện vẽ một đối tượng, sửa tên thuộc tính, hoặc liên kết bài viết Wiki.
* Bấm nút **Undo** trên thanh công cụ của Main Editor.
* Quan sát đối tượng biến mất/khôi phục lại thông tin cũ thành công.
@@ -0,0 +1,117 @@
# Hướng Dẫn Phân Chia Tính Năng Giữa Route Trang Chủ `/` Và Editor Preview Mode
Tài liệu này làm rõ sự khác biệt kiến trúc, cấu trúc tệp tin, phân chia trách nhiệm và các lưu ý kỹ thuật khi phát triển/chỉnh sửa tính năng giữa trang bản đồ tổng quan công cộng (Route `/`) và Chế độ xem trước của Trình biên tập (Editor Preview Mode).
---
## 1. Bản Đồ Tổng Quan Kiến Trúc (Architecture Map)
Hệ thống có hai môi trường tương tác bản đồ độc lập sử dụng chung một số thành phần lõi:
```mermaid
graph TD
A[Bản đồ số Lịch sử] --> B[Trang chủ công cộng Route /]
A --> C[Môi trường Editor /editor/id]
B --> D[PreviewMapShell.tsx]
D --> E[ReplayPreviewLayerPanel.tsx]
C --> F[PreviewLayout.tsx]
F --> E
```
### 1.1 Môi trường Trang Chủ công cộng (Route `/`)
* **Tệp tin chính**: `src/app/page.tsx`
* **Vỏ bọc bố cục (Shell Component)**: `src/uhm/components/preview/PreviewMapShell.tsx`
* **Mục đích**: Dành cho khách vãng lai khám phá bản đồ lịch sử thế giới công cộng, tra cứu địa danh lịch sử, chạy thử các replay được xuất bản công khai.
* **Đặc trưng**:
* Chứa thanh tìm kiếm `PresentPlaceSearch` nằm ở vị trí tuyệt đối (`left: 80px`, `top: 10px`).
***Menu Cài đặt** gấp gọn ở góc trên bên trái, cung cấp 3 liên kết nhanh: Quản trị & Chỉnh sửa (`/user`), Hỏi đáp (`/faq`), và Giới thiệu (`/about-us`).
* Trạng thái dòng thời gian (`timelineYear`) mặc định là **1000** và được đồng bộ tự động với `localStorage` (`timeline-year`).
### 1.2 Môi trường Xem trước của Trình biên tập (Editor Preview Mode)
* **Tệp tin chính**: `src/app/editor/[id]/page.tsx`
* **Vỏ bọc bố cục**: `src/uhm/components/preview/PreviewLayout.tsx`
* **Mục đích**: Dành cho Nhà sử học / Người biên tập xem trước bản nháp (snapshot draft) của dự án hiện tại trước khi commit hoặc nộp lên hệ thống.
* **Đặc trưng**:
* Tích hợp sâu vào Zustand Store (`useEditorStore`) để chia sẻ trạng thái chỉnh sửa hình học, liên kết thực thể (entity binding), và cấu hình replay.
* Hỗ trợ nút chuyển đổi dữ liệu cục bộ/toàn cầu (Local/Global View Mode) và đồng bộ tọa độ camera của trình chỉnh sửa.
---
## 2. Quy Tắc Phân Chia Tính Năng & Trách Nhiệm
Để tránh phá vỡ giao diện hoặc logic của môi trường còn lại khi chỉnh sửa, các Agent hoặc Developer cần tuân thủ quy tắc sau:
| Tính năng / Thành phần | Trang chủ (Route `/`) | Editor Preview | Lưu ý sửa đổi |
| :--- | :--- | :--- | :--- |
| **Thanh Tìm kiếm (`PresentPlaceSearch`)** | Khai báo tuyệt đối trực tiếp tại `src/app/page.tsx` | Khai báo bên trong `PreviewLayout.tsx` | Đảm bảo chiều rộng linh hoạt bằng cách sử dụng `min-width` / `max-width` thích hợp. |
| **Menu Cài đặt (Bánh răng)** | Nằm tại `PreviewMapShell.tsx` (chứa 3 link: Edit, FAQ, About Us) | Không hiển thị | Menu này chỉ phục vụ điều hướng công cộng. |
| **Layer Control Panel** | Nằm bên trong `PreviewMapShell.tsx` | Nằm bên trong `PreviewLayout.tsx` | Dùng component chung `ReplayPreviewLayerPanel.tsx`. |
| **Trạng thái Timeline** | Mặc định năm 1000, tự động tải/lưu qua `localStorage` | Không lưu `localStorage` (theo trạng thái nháp) | Chỉ áp dụng logic lưu trữ tại `src/app/page.tsx`. |
| **Bộ lọc Timeline (Toggle switch)** | Không hiển thị | Hiển thị và hoạt động trên cả hai chế độ (soạn thảo bình thường và xem trước) | Đảm bảo bộ lọc hoạt động đồng bộ với thực thể nháp (local draft). Tránh áp dụng lọc/truy vấn cho thực thể toàn cầu (global geometries) để không gây DDoS cho API backend. |
---
## 3. Các Cơ Chế Kỹ Thuật Đặc Biệt (Lưu ý cho các Agent khác)
### 3.1 Cơ chế chống Hydration Mismatch & Race Condition khi dùng `localStorage`
Tại Route `/`, `timelineYear` được lưu trong `localStorage`. Do Next.js chạy SSR trên server (nơi không có `window``localStorage`), ta phải xử lý tránh lệch HTML bằng cách:
1. Khởi tạo state bằng giá trị tĩnh (`1000`) trên cả server và client.
2. Dùng một `useEffect` chạy khi mount trên client để đọc dữ liệu từ `localStorage` ra nếu có.
3. Dùng một `useRef(true)` làm cờ hiệu `isFirstMount` để ngăn chặn `useEffect` ghi đè giá trị mặc định `1000` vào `localStorage` trước khi client kịp đọc dữ liệu cũ ra:
```typescript
const isFirstMount = useRef(true);
// 1. Đọc dữ liệu khi mount
useEffect(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("timeline-year");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed)) {
setTimelineYear(parsed);
setTimelineDraftYear(parsed);
}
}
}
}, []);
// 2. Ghi đè dữ liệu khi người dùng kéo thay đổi mốc năm
useEffect(() => {
if (isFirstMount.current) {
isFirstMount.current = false;
return;
}
if (typeof window !== "undefined") {
localStorage.setItem("timeline-year", String(timelineYear));
}
}, [timelineYear]);
```
### 3.2 Cơ chế Bố cục Flexbox của Sidebar góc trái
Thanh công cụ bên trái của trang chủ chứa cả **Menu Cài đặt****Layer Control Panel**. Để đảm bảo chúng không bao giờ đè lên nhau trên các màn hình có chiều cao thấp:
* Sử dụng một container `<aside>` định vị tuyệt đối với thuộc tính flex dọc (`display: flex`, `flexDirection: column`, `height` giới hạn trong viewport).
* Đặt thuộc tính `flexShrink: 1``minHeight: 0` cho vùng bao bọc `ReplayPreviewLayerPanel`.
* Tại `ReplayPreviewLayerPanel.tsx`, thuộc tính `maxHeight` của thẻ bọc chính được thiết lập là `100%``overflowY: "auto"`.
* **Kết quả**: Khi Menu Cài đặt mở rộng các nút tùy chọn xuống dưới, Layer Control Panel bên dưới sẽ tự động co nhỏ lại tương ứng và xuất hiện thanh cuộn nếu danh sách các lớp bản đồ vượt quá chiều cao còn lại.
### 3.3 Cơ chế Tự Động Co Giãn cho TimelineBar
Để thanh Timeline kéo dài tối đa chiều ngang nhưng không bị đè bởi Layer Control Panel (ở bên trái) và Wiki Sidebar (ở bên phải) khi mở rộng:
* Thuộc tính `left` được đặt cố định là `"88px"` để luôn đứng cách bên phải Layer Control Panel một khoảng an toàn (18px lề + 58px chiều rộng panel + 12px padding).
* Thuộc tính `right` được tính toán động thông qua hàm `useMemo`:
* **Khi đóng Sidebar**: `right` bằng `18px`.
* **Khi mở Sidebar**: `right` bằng `${sidebarWidth + 32}px`.
* Bằng cách đặt cả hai neo `left``right` mà không thiết lập `width` cố định hay `maxWidth`, trình duyệt sẽ tự động co giãn thanh Timeline giống như một phần tử `flex: 1` nằm giữa hai bên.
* Đồng thời thêm thuộc tính hiệu ứng `transition: "right 0.3s, left 0.3s"` giúp việc co giãn diễn ra mượt mà cùng tốc độ với thanh Sidebar.
---
## 4. Checklist Khi Chỉnh Sửa Cho Các Agent Tiếp Theo
* [ ] **Chỉnh sửa UI Sidebar / Layer Panel**: Đảm bảo kiểm tra giao diện trên cả màn hình desktop rộng và màn hình laptop/tablet có chiều cao nhỏ.
* [ ] **Sử dụng `localStorage`**: Tuyệt đối không đọc trực tiếp `localStorage` trong hàm khởi tạo `useState(() => localStorage.getItem(...))` vì sẽ gây ra lỗi Hydration Mismatch của Next.js SSR. Hãy luôn khởi tạo bằng giá trị tĩnh và cập nhật lại trong `useEffect` sau khi trang đã mount.
* [ ] **Cập nhật Style**: Sử dụng hệ thống Tailwind CSS có sẵn hoặc các thuộc tính inline CSS an toàn. Hạn chế tối đa việc ghi đè trực tiếp các lớp CSS toàn cục có thể ảnh hưởng xuyên suốt cả dự án.
* [ ] **Kiểm tra bộ lọc Timeline (Timeline Filter)**: Đảm bảo nút bật/tắt bộ lọc ở bên trái Timeline hoạt động chính xác trong cả chế độ soạn thảo bình thường và các chế độ Xem trước (chỉ trừ Replay Preview khi dòng thời gian bị khóa cứng theo replay). Khi bật bộ lọc, các hình học nháp (local draft) phải được lọc đồng bộ theo mốc năm đang hiển thị. Tuyệt đối không gộp/lọc các thực thể toàn cầu (global geometries) để tránh gọi API nặng nề gây DDoS cho backend.
* [ ] **Đảm bảo TypeScript xanh**: Luôn kiểm tra build bằng lệnh `npx tsc --noEmit` trước khi hoàn tất công việc để chắc chắn không xảy ra lỗi kiểu dữ liệu hoặc import sai đường dẫn tương đối.

Some files were not shown because too many files have changed in this diff Show More