Compare commits

..

10 Commits

Author SHA1 Message Date
BoKhongLo f904f91a9c update wiki page
Build and Release / release (push) Has been cancelled
2026-05-14 16:07:16 +07:00
BoKhongLo 3c71249926 update wiki 2026-05-14 12:55:10 +07:00
taDuc 8fc9456a6a init: replay mode 2026-05-14 03:41:58 +07:00
taDuc c92aaafc33 refactor: remove refresh token handling in favor of HttpOnly cookie-based authentication 2026-05-13 17:45:56 +07:00
taDuc f1d6f22f80 fix: refine token expiration detection and prevent unauthorized redirects for anonymous users while adding support for jwt property in token stores 2026-05-13 17:41:25 +07:00
taDuc 14a06af343 feat: implement updating circle 2026-05-13 15:48:00 +07:00
taDuc 41e43d4974 fix: stop use int key in local 2026-05-13 04:17:22 +07:00
taDuc 33a866b659 add new list view for ent - wiki 2026-05-13 02:40:48 +07:00
taDuc 08120ef987 refactor undo feature 2026-05-13 02:27:54 +07:00
taDuc e725b52590 editor panel improve experience 2026-05-12 21:54:56 +07:00
71 changed files with 2990 additions and 1961 deletions
+164 -61
View File
@@ -29,6 +29,7 @@
"maplibre-gl": "^5.20.2", "maplibre-gl": "^5.20.2",
"next": "^16.1.6", "next": "^16.1.6",
"polylabel": "^2.0.1", "polylabel": "^2.0.1",
"quill-blot-formatter": "^1.0.5",
"react": "^19.2.0", "react": "^19.2.0",
"react-apexcharts": "^1.8.0", "react-apexcharts": "^1.8.0",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
@@ -102,7 +103,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1955,7 +1955,6 @@
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"preact": "~10.12.1" "preact": "~10.12.1"
} }
@@ -3027,7 +3026,6 @@
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz",
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/Fuzzyma" "url": "https://github.com/sponsors/Fuzzyma"
@@ -3051,7 +3049,6 @@
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz",
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 14.18" "node": ">= 14.18"
}, },
@@ -3228,7 +3225,6 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/core": "^7.21.3", "@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0", "@svgr/babel-preset": "8.1.0",
@@ -3671,7 +3667,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@@ -3988,7 +3983,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-changeset": "^2.3.0", "prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1", "prosemirror-collab": "^1.3.1",
@@ -4136,7 +4130,6 @@
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -4154,7 +4147,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -4165,7 +4157,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -4240,7 +4231,6 @@
"integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/scope-manager": "8.58.0",
"@typescript-eslint/types": "8.58.0", "@typescript-eslint/types": "8.58.0",
@@ -4772,7 +4762,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4828,7 +4817,6 @@
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz",
"integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==", "integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@svgdotjs/svg.draggable.js": "^3.0.4", "@svgdotjs/svg.draggable.js": "^3.0.4",
"@svgdotjs/svg.filter.js": "^3.0.8", "@svgdotjs/svg.filter.js": "^3.0.8",
@@ -5240,7 +5228,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@@ -5259,7 +5246,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.0", "call-bind-apply-helpers": "^1.0.0",
@@ -5291,7 +5277,6 @@
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -5376,6 +5361,16 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "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",
"peer": true,
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -5660,6 +5655,27 @@
} }
} }
}, },
"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",
"peer": true,
"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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5681,7 +5697,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.0", "es-define-property": "^1.0.0",
@@ -5699,7 +5714,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"define-data-property": "^1.0.1", "define-data-property": "^1.0.1",
@@ -6103,7 +6117,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -6289,7 +6302,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -6530,10 +6542,18 @@
} }
}, },
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "5.0.4", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT" "license": "MIT",
"peer": true
},
"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",
"peer": true
}, },
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
@@ -6782,7 +6802,6 @@
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -6970,7 +6989,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.0" "es-define-property": "^1.0.0"
@@ -7122,6 +7140,23 @@
"node": ">= 0.4" "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",
"peer": true,
"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": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -7274,7 +7309,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -7403,7 +7437,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -7589,8 +7622,7 @@
"version": "3.7.1", "version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
@@ -8453,11 +8485,27 @@
"url": "https://github.com/sponsors/ljharb" "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",
"peer": true,
"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": { "node_modules/object-keys": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -8628,10 +8676,11 @@
} }
}, },
"node_modules/parchment": { "node_modules/parchment": {
"version": "3.0.0", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause",
"peer": true
}, },
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
@@ -8771,7 +8820,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -8936,7 +8984,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"orderedmap": "^2.0.0" "orderedmap": "^2.0.0"
} }
@@ -8966,7 +9013,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.0.0", "prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0", "prosemirror-transform": "^1.0.0",
@@ -9015,7 +9061,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.20.0", "prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0", "prosemirror-state": "^1.0.0",
@@ -9084,18 +9129,39 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/quill": { "node_modules/quill": {
"version": "2.0.3", "version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"dependencies": { "dependencies": {
"eventemitter3": "^5.0.1", "clone": "^2.1.1",
"lodash-es": "^4.17.21", "deep-equal": "^1.0.1",
"parchment": "^3.0.0", "eventemitter3": "^2.0.3",
"quill-delta": "^5.1.0" "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": { "engines": {
"npm": ">=8.2.3" "node": ">=0.10.0"
} }
}, },
"node_modules/quill-delta": { "node_modules/quill-delta": {
@@ -9103,7 +9169,6 @@
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-diff": "^1.3.0", "fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
@@ -9113,12 +9178,33 @@
"node": ">= 12.0.0" "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",
"peer": true
},
"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",
"peer": true,
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -9180,7 +9266,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -9226,12 +9311,38 @@
"react-dom": "^16 || ^17 || ^18 || ^19" "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": { "node_modules/react-redux": {
"version": "9.2.0", "version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@@ -9254,8 +9365,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@@ -9313,7 +9423,6 @@
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.8", "call-bind": "^1.0.8",
@@ -9556,7 +9665,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"define-data-property": "^1.1.4", "define-data-property": "^1.1.4",
@@ -9574,7 +9682,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"define-data-property": "^1.1.4", "define-data-property": "^1.1.4",
@@ -10079,8 +10186,7 @@
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.2", "version": "2.3.2",
@@ -10136,7 +10242,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -10314,7 +10419,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -10693,7 +10797,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+1
View File
@@ -30,6 +30,7 @@
"maplibre-gl": "^5.20.2", "maplibre-gl": "^5.20.2",
"next": "^16.1.6", "next": "^16.1.6",
"polylabel": "^2.0.1", "polylabel": "^2.0.1",
"quill-blot-formatter": "^1.0.5",
"react": "^19.2.0", "react": "^19.2.0",
"react-apexcharts": "^1.8.0", "react-apexcharts": "^1.8.0",
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
+17 -8
View File
@@ -12,6 +12,10 @@ import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionType
type EditorDraftApi = { type EditorDraftApi = {
patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial<FeatureProperties>) => void; patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial<FeatureProperties>) => void;
patchFeaturePropertiesBatch: (
patches: Array<{ id: FeatureProperties["id"]; patch: Partial<FeatureProperties> }>,
label?: string
) => void;
}; };
type Options = { type Options = {
@@ -64,9 +68,13 @@ export function useFeatureCommands(options: Options) {
setIsEntitySubmitting(true); setIsEntitySubmitting(true);
setEntityFormStatus(null); setEntityFormStatus(null);
try { try {
for (const feature of selectedFeatures) { editor.patchFeaturePropertiesBatch(
editor.patchFeatureProperties(feature.properties.id, metadata.patch); selectedFeatures.map((feature) => ({
} id: feature.properties.id,
patch: metadata.patch,
})),
"Cập nhật thuộc tính GEO"
);
setGeometryMetaForm(metadata.formState); setGeometryMetaForm(metadata.formState);
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng."); setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
return { ok: true }; return { ok: true };
@@ -92,12 +100,13 @@ export function useFeatureCommands(options: Options) {
setIsEntitySubmitting(true); setIsEntitySubmitting(true);
setEntityFormStatus(null); setEntityFormStatus(null);
try { try {
for (const feature of selectedFeatures) { editor.patchFeaturePropertiesBatch(
editor.patchFeatureProperties( selectedFeatures.map((feature) => ({
feature.properties.id, id: feature.properties.id,
buildFeatureEntityPatch(feature, entityIds, entities) patch: buildFeatureEntityPatch(feature, entityIds, entities),
})),
"Cập nhật entity cho GEO"
); );
}
setSelectedGeometryEntityIds(entityIds); setSelectedGeometryEntityIds(entityIds);
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng."); setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
} catch (err) { } catch (err) {
+143 -76
View File
@@ -24,7 +24,7 @@ import {
Geometry, Geometry,
useEditorState, useEditorState,
} from "@/uhm/lib/editor/state/useEditorState"; } from "@/uhm/lib/editor/state/useEditorState";
import { GEO_TYPE_KEYS, geoTypeCodeToTypeKey } from "@/uhm/lib/map/geo/geoTypeMap"; import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
import { import {
BackgroundLayerId, BackgroundLayerId,
BackgroundLayerVisibility, BackgroundLayerVisibility,
@@ -47,13 +47,9 @@ import {
} from "@/uhm/lib/editor/snapshot/editorSnapshot"; } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import { import {
buildClientEntityId, buildClientEntityId,
formatEntityNamesForDisplay,
mergeEntitySearchResults, mergeEntitySearchResults,
} from "@/uhm/lib/editor/entity/entityBinding"; } from "@/uhm/lib/editor/entity/entityBinding";
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding"; import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
import {
formatBindingIdsForDisplay,
} from "@/uhm/lib/editor/geometry/geometryMetadata";
import { import {
loadBackgroundLayerVisibilityFromStorage, loadBackgroundLayerVisibilityFromStorage,
persistBackgroundLayerVisibility, persistBackgroundLayerVisibility,
@@ -98,11 +94,15 @@ export default function Page() {
key: number; key: number;
collection: FeatureCollection; collection: FeatureCollection;
} | null>(null); } | null>(null);
const localCreatedEntityIdsRef = useRef<Set<string>>(new Set());
const lastSelectedFeatureIdRef = useRef<string | null>(null); const lastSelectedFeatureIdRef = useRef<string | null>(null);
const [replayFeatureId, setReplayFeatureId] = useState<string | number | null>(null);
const [hideOutside, setHideOutside] = useState(false);
const { const {
mode, mode,
setMode, setMode: internalSetMode,
initialData, initialData,
setInitialData, setInitialData,
isSaving, isSaving,
@@ -244,6 +244,27 @@ export default function Page() {
return Array.from(byId.values()); return Array.from(byId.values());
}, [snapshotEntities]); }, [snapshotEntities]);
useEffect(() => {
const localCreatedIds = localCreatedEntityIdsRef.current;
if (!localCreatedIds.size) return;
const snapshotIds = new Set((snapshotEntities || []).map((entity) => String(entity.id || "")));
setEntityCatalog((prev) => {
let changed = false;
const next = (prev || []).filter((entity) => {
const id = String(entity?.id || "");
const shouldDrop = localCreatedIds.has(id) && !snapshotIds.has(id);
if (shouldDrop) {
changed = true;
localCreatedIds.delete(id);
return false;
}
return true;
});
return changed ? next : prev;
});
}, [snapshotEntities, setEntityCatalog]);
// Timeline filter: only affects persisted snapshot features. // Timeline filter: only affects persisted snapshot features.
// New features created in the current session remain visible regardless of time range. // New features created in the current session remain visible regardless of time range.
const timelineVisibleDraft = useMemo(() => { const timelineVisibleDraft = useMemo(() => {
@@ -262,8 +283,13 @@ export default function Page() {
const ids = new Set<string>(); const ids = new Set<string>();
for (const ref of snapshotEntitiesVisible) ids.add(String(ref.id)); for (const ref of snapshotEntitiesVisible) ids.add(String(ref.id));
const rows = Array.from(ids).map((id) => { const rows = Array.from(ids).map((id) => {
const ref = snapshotEntitiesVisible.find((entity) => String(entity.id) === id) || null;
const found = entities.find((e) => e.id === id) || null; const found = entities.find((e) => e.id === id) || null;
return { id, name: found?.name || id }; return {
id,
name: found?.name || id,
isNew: ref?.source === "inline" && ref?.operation === "create",
};
}); });
rows.sort((a, b) => a.name.localeCompare(b.name)); rows.sort((a, b) => a.name.localeCompare(b.name));
return rows; return rows;
@@ -310,41 +336,6 @@ export default function Page() {
return normalizeFeatureBindingIds(selectedFeature); return normalizeFeatureBindingIds(selectedFeature);
}, [selectedFeature]); }, [selectedFeature]);
const createdEntities = useMemo(() => {
return (snapshotEntities || [])
.filter((e) => e && e.source === "inline" && e.operation === "create")
.map((e) => ({
id: String(e.id || ""),
name: String(e.name || "").trim() || String(e.id || ""),
}))
.filter((e) => e.id.length > 0 && e.name.length > 0);
}, [snapshotEntities]);
const createdGeometries = useMemo(() => {
const rows: Array<{
id: string | number;
geometryType: string;
semanticType?: string | null;
entityNames: string[];
}> = [];
for (const change of editor.changes.values()) {
if (change.action !== "create") continue;
const feature = change.feature;
const entityNames = normalizeFeatureEntityIds(feature)
.map((entityId) => entities.find((entity) => entity.id === entityId)?.name || entityId);
rows.push({
id: feature.properties.id,
geometryType: feature.geometry.type,
semanticType: feature.properties.type || getDefaultTypeIdForFeature(feature),
entityNames,
});
}
return rows;
}, [editor.changes, entities]);
const wikiDirty = useMemo(() => { const wikiDirty = useMemo(() => {
const prev = normalizeWikisForCompare(baselineSnapshot?.wikis); const prev = normalizeWikisForCompare(baselineSnapshot?.wikis);
const next = normalizeWikisForCompare(snapshotWikis); const next = normalizeWikisForCompare(snapshotWikis);
@@ -421,6 +412,52 @@ export default function Page() {
restoreCommit, restoreCommit,
} = sectionCommands; } = sectionCommands;
const setMode = useCallback((m: EditorMode, featureId?: string | number) => {
if (m === "replay" && featureId) {
setReplayFeatureId(featureId);
} else if (m !== "replay") {
setReplayFeatureId(null);
setHideOutside(false);
}
internalSetMode(m);
}, [internalSetMode]);
const onSetMode = setMode;
const effectiveGeometryVisibility = useMemo(() => {
const visibility: Record<string, boolean> = { ...geometryVisibility };
if (mode === "replay" && replayFeatureId) {
// Ẩn chính geo được chọn làm replay
visibility[String(replayFeatureId)] = false;
if (hideOutside) {
// Tìm feature đang replay để lấy danh sách binding
const replayFeature = editor.draft.features.find(
(f) => String(f.properties.id) === String(replayFeatureId)
);
const boundIds = new Set<string>();
if (replayFeature?.properties?.binding) {
replayFeature.properties.binding.forEach((id: string) => boundIds.add(String(id)));
}
// Ẩn tất cả các geo không nằm trong binding
editor.draft.features.forEach((f) => {
const fid = String(f.properties.id);
if (fid !== String(replayFeatureId) && !boundIds.has(fid)) {
visibility[fid] = false;
}
});
}
}
return visibility;
}, [geometryVisibility, mode, replayFeatureId, hideOutside, editor.draft.features]);
const onToggleHideOutside = useCallback(() => {
setHideOutside((prev) => !prev);
}, []);
const openProject = useCallback(async () => { const openProject = useCallback(async () => {
if (!projectId) return; if (!projectId) return;
try { try {
@@ -826,10 +863,6 @@ export default function Page() {
setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
}; };
const handleEntityIdsChange = (values: string[]) => {
setSelectedGeometryEntityIds(uniqueEntityIds(values));
};
const handleAddEntityRefToProject = useCallback((entity: Entity) => { const handleAddEntityRefToProject = useCallback((entity: Entity) => {
const id = String(entity.id || "").trim(); const id = String(entity.id || "").trim();
if (!id) return; if (!id) return;
@@ -914,12 +947,13 @@ export default function Page() {
setIsEntitySubmitting(true); setIsEntitySubmitting(true);
flashEntityFormStatus(null, 0); flashEntityFormStatus(null, 0);
try { try {
for (const feature of selectedFeatures) { editor.patchFeaturePropertiesBatch(
editor.patchFeatureProperties( selectedFeatures.map((feature) => ({
feature.properties.id, id: feature.properties.id,
buildFeatureEntityPatch(feature, nextEntityIds, entities) patch: buildFeatureEntityPatch(feature, nextEntityIds, entities),
})),
nextChecked ? "Bind entity vào GEO" : "Unbind entity khỏi GEO"
); );
}
setSelectedGeometryEntityIds(nextEntityIds); setSelectedGeometryEntityIds(nextEntityIds);
flashEntityFormStatus( flashEntityFormStatus(
nextChecked nextChecked
@@ -959,7 +993,7 @@ export default function Page() {
setIsEntitySubmitting(true); setIsEntitySubmitting(true);
flashGeoBindingStatus(null, 0); flashGeoBindingStatus(null, 0);
try { try {
for (const feature of selectedFeatures) { const bindingPatches = selectedFeatures.map((feature) => {
const prevBindingIds = normalizeFeatureBindingIds(feature); const prevBindingIds = normalizeFeatureBindingIds(feature);
const has = prevBindingIds.includes(id); const has = prevBindingIds.includes(id);
const nextBindingIds = (() => { const nextBindingIds = (() => {
@@ -970,8 +1004,15 @@ export default function Page() {
if (!has) return prevBindingIds; if (!has) return prevBindingIds;
return prevBindingIds.filter((x) => x !== id); return prevBindingIds.filter((x) => x !== id);
})(); })();
editor.patchFeatureProperties(feature.properties.id, { binding: nextBindingIds }); return {
} id: feature.properties.id,
patch: { binding: nextBindingIds },
};
});
editor.patchFeaturePropertiesBatch(
bindingPatches,
nextChecked ? "Bind geometry vào GEO" : "Unbind geometry khỏi GEO"
);
// Assume selectedFeature (the first one) reflects the representative binding in UI // Assume selectedFeature (the first one) reflects the representative binding in UI
const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]); const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]);
@@ -1064,17 +1105,18 @@ export default function Page() {
// Ensure the geometry stays selectable even if it doesn't match the current timeline year. // Ensure the geometry stays selectable even if it doesn't match the current timeline year.
setTimelineFilterEnabled(false); setTimelineFilterEnabled(false);
// Keep entity store consistent: importing a geo implies the entity should exist in snapshot + catalog. const importedEntity: Entity = {
handleAddEntityRefToProject({
id: entityItem.entity_id, id: entityItem.entity_id,
name: (entityItem.name || "").trim() || entityItem.entity_id, name: (entityItem.name || "").trim() || entityItem.entity_id,
description: (entityItem.description || "").trim() || null, description: (entityItem.description || "").trim() || null,
status: 1, status: 1,
geometry_count: 0, geometry_count: 0,
}); };
const existing = editor.draft.features.find((f) => String(f.properties.id) === geoId) || null; const existing = editor.draft.features.find((f) => String(f.properties.id) === geoId) || null;
if (existing) { if (existing) {
// Keep entity store consistent: importing/selecting a geo implies the entity should exist in snapshot + catalog.
handleAddEntityRefToProject(importedEntity);
setSelectedFeatureIds([existing.properties.id]); setSelectedFeatureIds([existing.properties.id]);
flashEntityFormStatus("Đã chọn geometry từ kết quả search.", 3000); flashEntityFormStatus("Đã chọn geometry từ kết quả search.", 3000);
return; return;
@@ -1087,7 +1129,7 @@ export default function Page() {
} }
const bindingIds = normalizeGeoSearchBindingIds(geo.binding); const bindingIds = normalizeGeoSearchBindingIds(geo.binding);
const typeKey = geoTypeCodeToTypeKey(Number(geo.geo_type)) || null; const typeKey = geo.type || null;
const feature: Feature = { const feature: Feature = {
type: "Feature", type: "Feature",
@@ -1105,13 +1147,39 @@ export default function Page() {
geometry, geometry,
}; };
editor.createFeature(feature); editor.createFeatureWithSnapshotEntities(
feature,
(prev) => {
if (prev.some((e) => String(e.id) === importedEntity.id)) return prev;
return [
{
id: importedEntity.id,
source: "ref",
operation: "reference",
name: importedEntity.name,
description: importedEntity.description ?? null,
},
...prev,
];
},
`Import GEO #${geoId}`
);
setEntityCatalog((prev) => {
const byId = new globalThis.Map<string, Entity>();
for (const row of prev || []) {
if (!row?.id) continue;
byId.set(String(row.id), row);
}
byId.set(importedEntity.id, importedEntity);
return Array.from(byId.values());
});
setSelectedFeatureIds([feature.properties.id]); setSelectedFeatureIds([feature.properties.id]);
flashEntityFormStatus("Đã import geometry từ search GEO. Commit khi sẵn sàng.", 3000); flashEntityFormStatus("Đã import geometry từ search GEO. Commit khi sẵn sàng.", 3000);
}, [ }, [
editor, editor,
flashEntityFormStatus, flashEntityFormStatus,
handleAddEntityRefToProject, handleAddEntityRefToProject,
setEntityCatalog,
setSelectedFeatureIds, setSelectedFeatureIds,
setTimelineFilterEnabled, setTimelineFilterEnabled,
]); ]);
@@ -1170,6 +1238,7 @@ export default function Page() {
...prev, ...prev,
]; ];
}, `Tạo entity #${entityId}`); }, `Tạo entity #${entityId}`);
localCreatedEntityIdsRef.current.add(entityId);
setEntityCatalog((prev) => { setEntityCatalog((prev) => {
const byId = new globalThis.Map<string, Entity>(); const byId = new globalThis.Map<string, Entity>();
for (const row of prev || []) { for (const row of prev || []) {
@@ -1203,6 +1272,8 @@ export default function Page() {
return ( return (
<div style={{ display: "flex", minHeight: "100vh" }}> <div style={{ display: "flex", minHeight: "100vh" }}>
{mode !== "replay" && (
<>
<Editor <Editor
mode={mode} mode={mode}
setMode={setMode} setMode={setMode}
@@ -1224,8 +1295,6 @@ export default function Page() {
commits={sectionCommits} commits={sectionCommits}
changesCount={pendingSaveCount} changesCount={pendingSaveCount}
undoStack={editor.undoStack} undoStack={editor.undoStack}
createdEntities={createdEntities}
createdGeometries={createdGeometries}
width={leftPanelWidth} width={leftPanelWidth}
/> />
@@ -1235,6 +1304,8 @@ export default function Page() {
setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520)); setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520));
}} }}
/> />
</>
)}
{blockedPendingSubmissionId ? ( {blockedPendingSubmissionId ? (
<div style={{ flex: 1, minHeight: "100vh", background: "#0b1220", color: "white", padding: "24px" }}> <div style={{ flex: 1, minHeight: "100vh", background: "#0b1220", color: "white", padding: "24px" }}>
@@ -1283,6 +1354,7 @@ export default function Page() {
{isBackgroundVisibilityReady ? ( {isBackgroundVisibilityReady ? (
<Map <Map
mode={mode} mode={mode}
onSetMode={setMode}
draft={timelineVisibleDraft} draft={timelineVisibleDraft}
labelContextDraft={editor.draft} labelContextDraft={editor.draft}
selectedFeatureIds={selectedFeatureIds} selectedFeatureIds={selectedFeatureIds}
@@ -1291,15 +1363,18 @@ export default function Page() {
onDeleteFeature={editor.deleteFeature} onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature} onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility} backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility} geometryVisibility={effectiveGeometryVisibility}
respectBindingFilter={geometryBindingFilterEnabled} respectBindingFilter={geometryBindingFilterEnabled}
focusFeatureCollection={geometryFocusRequest?.collection || null} focusFeatureCollection={geometryFocusRequest?.collection || null}
focusRequestKey={geometryFocusRequest?.key ?? null} focusRequestKey={geometryFocusRequest?.key ?? null}
focusPadding={96} focusPadding={96}
hideOutside={hideOutside}
onToggleHideOutside={onToggleHideOutside}
/> />
) : ( ) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} /> <div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
)} )}
{mode !== "replay" && (
<TimelineBar <TimelineBar
year={timelineDraftYear} year={timelineDraftYear}
onYearChange={handleTimelineYearChange} onYearChange={handleTimelineYearChange}
@@ -1309,12 +1384,15 @@ export default function Page() {
filterEnabled={timelineFilterEnabled} filterEnabled={timelineFilterEnabled}
onFilterEnabledChange={setTimelineFilterEnabled} onFilterEnabledChange={setTimelineFilterEnabled}
/> />
)}
</div> </div>
) : blockedPendingSubmissionId ? null : ( ) : blockedPendingSubmissionId ? null : (
// Wiki-only mode: avoid mounting Map/Timeline (WebGL + geometry fetching) to reduce lag. // Wiki-only mode: avoid mounting Map/Timeline (WebGL + geometry fetching) to reduce lag.
<div style={{ flex: 1, minHeight: "100vh", background: "#0b1220" }} /> <div style={{ flex: 1, minHeight: "100vh", background: "#0b1220" }} />
)} )}
{mode !== "replay" && (
<>
<ResizeHandle <ResizeHandle
title="Resize right panel" title="Resize right panel"
onDrag={(deltaX) => { onDrag={(deltaX) => {
@@ -1519,7 +1597,7 @@ export default function Page() {
#{geo.id} #{geo.id}
</div> </div>
<div style={{ color: "#94a3b8", fontSize: 11 }}> <div style={{ color: "#94a3b8", fontSize: 11 }}>
type: {String(geo.geo_type)}{" "} type: {geo.type || "unknown"}{" "}
{geo.time_start != null || geo.time_end != null {geo.time_start != null || geo.time_end != null
? `| time: ${geo.time_start ?? "?"}${geo.time_end ?? "?"}` ? `| time: ${geo.time_start ?? "?"}${geo.time_end ?? "?"}`
: ""} : ""}
@@ -1600,19 +1678,6 @@ export default function Page() {
{!wikiOnly && selectedFeature ? ( {!wikiOnly && selectedFeature ? (
<SelectedGeometryPanel <SelectedGeometryPanel
selectedFeatures={selectedFeatures} selectedFeatures={selectedFeatures}
selectedFeatureEntitySummary={
selectedFeature
? formatEntityNamesForDisplay(selectedFeature, entities)
: "Chưa gắn"
}
selectedFeatureBindingSummary={
selectedFeature
? formatBindingIdsForDisplay(selectedFeature)
: "Không có"
}
entities={entities}
selectedGeometryEntityIds={selectedGeometryEntityIds}
onEntityIdsChange={handleEntityIdsChange}
entityTypeOptions={GEOMETRY_TYPE_OPTIONS} entityTypeOptions={GEOMETRY_TYPE_OPTIONS}
geometryMetaForm={geometryMetaForm} geometryMetaForm={geometryMetaForm}
onGeometryMetaFormChange={handleGeometryMetaFormChange} onGeometryMetaFormChange={handleGeometryMetaFormChange}
@@ -1624,6 +1689,8 @@ export default function Page() {
</div> </div>
} }
/> />
</>
)}
</div> </div>
); );
} }
+276 -144
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import "react-quill-new/dist/quill.snow.css"; import "react-quill-new/dist/quill.snow.css";
import { ApiError } from "@/uhm/api/http"; import { ApiError } from "@/uhm/api/http";
import { fetchWikiBySlug, type Wiki } from "@/uhm/api/wikis"; import { fetchWikiBySlug, getContentByVersionWikiId, type Wiki } from "@/uhm/api/wikis";
type TocItem = { type TocItem = {
id: string; id: string;
@@ -141,18 +141,33 @@ function rewriteHtmlAndBuildToc(inputHtml: string, wikiBaseUrl: string): { html:
return { html: doc.body.innerHTML, toc }; return { html: doc.body.innerHTML, toc };
} }
function formatDate(value?: string | null): string { function formatDate(value?: string | null, options?: Intl.DateTimeFormatOptions): string {
const raw = String(value || "").trim(); const raw = String(value || "").trim();
if (!raw) return "-"; if (!raw) return "-";
const d = new Date(raw); const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw; if (Number.isNaN(d.getTime())) return raw;
return d.toLocaleString(); return d.toLocaleString(
"vi-VN",
options || {
hour: "2-digit",
minute: "2-digit",
day: "numeric",
month: "long",
year: "numeric",
}
);
} }
export default function WikiBySlugClient({ slug }: { slug: string }) { export default function WikiBySlugClient({ slug }: { slug: string }) {
const [wiki, setWiki] = useState<Wiki | null>(null); const [wiki, setWiki] = useState<Wiki | null>(null);
const [status, setStatus] = useState<"idle" | "loading" | "error" | "ready">("idle"); const [status, setStatus] = useState<"idle" | "loading" | "error" | "ready">("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"read" | "history" | "compare">("read");
const [selectedVersionsForCompare, setSelectedVersionsForCompare] = useState<Set<string>>(new Set());
const [comparisonData, setComparisonData] = useState<{ id: string; content: string; createdAt: string; title: string }[]>([]);
const [isComparing, setIsComparing] = useState(false);
const [renderHtml, setRenderHtml] = useState<string>(""); const [renderHtml, setRenderHtml] = useState<string>("");
const [toc, setToc] = useState<TocItem[]>([]); const [toc, setToc] = useState<TocItem[]>([]);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null); const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
@@ -176,6 +191,21 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
const hidePreviewTimerRef = useRef<number | null>(null); const hidePreviewTimerRef = useRef<number | null>(null);
const previewCacheRef = useRef<Map<string, { title: string; quote: string | null }>>(new Map()); const previewCacheRef = useRef<Map<string, { title: string; quote: string | null }>>(new Map());
const allVersions = useMemo(() => {
if (!wiki) return [];
const current = {
id: wiki.id,
created_at: wiki.updated_at,
content: wiki.content,
isCurrent: true,
};
const history = (wiki.content_sample || []).map(s => ({ ...s, isCurrent: false }));
const uniqueHistory = history.filter(h => h.id !== current.id);
const combined = [current, ...uniqueHistory];
return combined
.filter(v => v.id && v.created_at)
.sort((a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime());
}, [wiki]);
// Load wiki data by slug. // Load wiki data by slug.
useEffect(() => { useEffect(() => {
const value = String(normalizedSlug || "").trim(); const value = String(normalizedSlug || "").trim();
@@ -192,6 +222,18 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
setError(null); setError(null);
try { try {
const res = await fetchWikiBySlug(value); const res = await fetchWikiBySlug(value);
let versionContent = res?.content;
try {
if (res?.content_sample?.[0]?.id) {
const contentResp = await getContentByVersionWikiId(res.content_sample[0].id);
if (contentResp?.data?.content) {
versionContent = contentResp.data.content;
}
}
} catch (err) {
console.error("Failed to fetch version content:", err);
}
if (disposed) return; if (disposed) return;
if (!res) { if (!res) {
setWiki(null); setWiki(null);
@@ -200,7 +242,7 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
setToc([]); setToc([]);
return; return;
} }
setWiki(res); setWiki({ ...res, content: versionContent });
setStatus("ready"); setStatus("ready");
} catch (err) { } catch (err) {
if (disposed) return; if (disposed) return;
@@ -424,107 +466,210 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
}; };
}, [renderHtml]); }, [renderHtml]);
const handleToggleVersionForCompare = (versionId: string) => {
setSelectedVersionsForCompare(prev => {
const next = new Set(prev);
if (next.has(versionId)) {
next.delete(versionId);
} else {
if (next.size >= 3) {
return prev; // Do not allow selecting more than 3
}
next.add(versionId);
}
return next;
});
};
const handleCompareVersions = async () => {
if (selectedVersionsForCompare.size < 1) {
alert("Vui lòng chọn ít nhất 1 phiên bản để so sánh.");
return;
}
setIsComparing(true);
setError(null);
try {
const versionsToFetch = Array.from(selectedVersionsForCompare);
const promises = versionsToFetch.map(async (versionId) => {
const sample = allVersions.find(s => s.id === versionId);
const versionInfo = {
id: versionId,
createdAt: sample?.created_at || 'Unknown date',
title: `Phiên bản lúc ${formatDate(sample?.created_at)}`
};
if (sample?.isCurrent) {
return { ...versionInfo, content: sample.content || '' };
}
const contentResp = await getContentByVersionWikiId(versionId);
return { ...versionInfo, content: contentResp?.data?.content || "" };
});
const results = await Promise.all(promises);
const processedResults = results.map(r => {
const { html } = rewriteHtmlAndBuildToc(normalizeWikiContentToHtml(r.content), `${window.location.origin}/wiki/`);
return { ...r, content: html };
});
setComparisonData(processedResults.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
setViewMode("compare");
} catch (err) {
const msg = err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Lỗi khi tải phiên bản để so sánh.";
setError(msg);
setViewMode("read");
} finally {
setIsComparing(false);
}
};
return ( return (
<div className="min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100"> <div className="min-h-screen bg-[#f8f9fa] text-[#202122] font-sans">
<div className="mx-auto max-w-6xl px-4 py-6"> <header className="bg-white border-b border-gray-300 px-6 py-2 flex justify-between items-center">
<div className="mb-5 flex items-start justify-between gap-4"> <div className="text-lg font-bold">GeoHistory Wiki</div>
<div className="min-w-0"> <Link href="/" className="text-sm text-blue-600 hover:underline">Trang chủ</Link>
<div className="text-xs text-gray-500 dark:text-gray-400">Wiki</div> </header>
<h1 className="mt-1 text-2xl font-bold leading-tight break-words">
{wiki?.title?.trim() || normalizedSlug || "Wiki"} <div className={viewMode === 'compare' ? '' : 'mx-auto max-w-7xl px-4 sm:px-6 py-6'}>
{status === "loading" && <div className="text-center p-10">Đang tải...</div>}
{status === "error" && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">{error}</div>}
{status === "ready" && !wiki && <div className="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative">Không tìm thấy wiki với slug: <strong>{normalizedSlug}</strong></div>}
{status === "ready" && wiki && (
<>
<div className={viewMode === 'compare' ? 'mx-auto max-w-7xl px-4 sm:px-6 py-6' : ''}>
<h1 className="text-3xl pb-2 mb-1">
{wiki.title?.trim() || normalizedSlug}
</h1> </h1>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-300"> {viewMode === 'compare' && (
<span className="break-all"> <div className="mt-4 p-3 border border-gray-300 bg-white rounded-sm text-xs space-y-1">
<span className="font-semibold">Slug:</span> {normalizedSlug || "-"} <div><span className="font-semibold">Slug:</span> {normalizedSlug || "-"}</div>
</span> <div><span className="font-semibold">ID:</span> {wiki.id || "-"}</div>
<span className="break-all"> <div><span className="font-semibold">Dự án:</span> {wiki.project_id || "-"}</div>
<span className="font-semibold">ID:</span> {wiki?.id || "-"} <div><span className="font-semibold">Tạo lúc:</span> {formatDate(wiki.created_at)}</div>
</span> <div><span className="font-semibold">Cập nhật:</span> {formatDate(wiki.updated_at)}</div>
<span className="break-all">
<span className="font-semibold">Project:</span>{" "}
{wiki?.project_id || "-"}
</span>
<span>
<span className="font-semibold">Created:</span> {formatDate(wiki?.created_at)}
</span>
<span>
<span className="font-semibold">Updated:</span> {formatDate(wiki?.updated_at)}
</span>
</div> </div>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className={`grid grid-cols-1 ${viewMode === 'compare' ? '' : 'lg:grid-cols-[minmax(0,1fr)_auto] gap-8 items-start'}`}>
<Link <main className={`min-w-0 bg-white ${viewMode === 'compare' ? 'border-y border-gray-300' : 'border border-gray-300 rounded-sm'}`}>
href="/" <div className={`flex border-b border-gray-300 text-sm ${viewMode === 'compare' ? 'mx-auto max-w-7xl px-4 sm:px-6' : ''}`}>
className="h-9 inline-flex items-center rounded-lg border border-gray-200 dark:border-gray-800 px-3 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/[0.04] transition" <button onClick={() => setViewMode('read')} className={`px-4 py-2 ${viewMode === 'read' ? 'border-b-2 border-blue-600 text-blue-700' : 'text-gray-600'}`}>Bài viết</button>
> <button onClick={() => setViewMode('history')} className={`px-4 py-2 ${viewMode === 'history' || viewMode === 'compare' ? 'border-b-2 border-blue-600 text-blue-700' : 'text-gray-600'}`}>Xem lịch sử</button>
Home
</Link>
</div>
</div> </div>
{status === "loading" ? ( {viewMode === 'read' && (
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-6"> <div ref={contentRootRef} className="uhm-wiki-view ql-editor wiki-article" dangerouslySetInnerHTML={{ __html: renderHtml }} />
<div className="h-5 w-40 rounded bg-gray-100 dark:bg-white/[0.06] animate-pulse" /> )}
<div className="mt-3 h-4 w-2/3 rounded bg-gray-100 dark:bg-white/[0.06] animate-pulse" />
{viewMode === 'history' && (
<div className="p-4">
<h2 className="text-xl mb-4 font-normal">Lịch sử phiên bản của "{wiki.title}"</h2>
<div className="flex gap-4 items-center mb-4">
<button onClick={handleCompareVersions} disabled={isComparing || selectedVersionsForCompare.size === 0} className="px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300">
{isComparing ? 'Đang tải...' : `So sánh ${selectedVersionsForCompare.size} phiên bản đã chọn`}
</button>
</div> </div>
) : status === "error" ? ( <div className="border rounded-md overflow-hidden">
<div className="rounded-xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/30 p-6 text-sm text-red-700 dark:text-red-200"> <table className="w-full text-sm text-left">
{error || "Failed to load wiki."} <thead className="bg-gray-100">
<tr>
<th className="p-2 w-16 text-center">So sánh</th>
<th className="p-2">Ngày cập nhật</th>
<th className="p-2">Ghi chú</th>
</tr>
</thead>
<tbody>
{allVersions.map((v) => {
const isChecked = selectedVersionsForCompare.has(v.id!);
const isDisabled = !isChecked && selectedVersionsForCompare.size >= 3;
return (
<tr key={v.id} className={`border-t ${isDisabled ? "opacity-50" : ""}`}>
<td className="p-2 text-center">
<input
type="checkbox"
onChange={() => handleToggleVersionForCompare(v.id!)}
checked={isChecked}
disabled={isDisabled}
className="h-4 w-4 disabled:cursor-not-allowed"
/>
</td>
<td className="p-2 text-blue-600">{formatDate(v.created_at)}</td>
<td className="p-2">{v.isCurrent && <span className="font-bold">(Phiên bản hiện tại)</span>}</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
) : wiki == null ? (
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-6 text-sm text-gray-700 dark:text-gray-200">
Không tìm thấy wiki với slug: <span className="font-semibold break-all">{normalizedSlug}</span>
</div> </div>
) : ( )}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="lg:sticky lg:top-6 self-start rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4"> {viewMode === 'compare' && (
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">Mục lục</div> <div className="p-4">
{!toc.length ? ( <div className="mx-auto max-w-7xl px-4 sm:px-6">
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Không có tu đ (H1/H2/...).</div> <h2 className="text-xl mb-4 font-normal">So sánh các phn bản</h2>
) : ( </div>
<nav className="mt-3 max-h-[70vh] overflow-auto pr-1"> <div className={`grid grid-cols-1 md:grid-cols-2 gap-4 ${comparisonData.length >= 3 ? 'xl:grid-cols-3' : ''} mx-auto px-4 sm:px-6`}>
<div className="grid gap-1"> {comparisonData.map(version => (
<div key={version.id} className="border rounded-lg overflow-hidden bg-white">
<h3 className="p-2 border-b font-semibold bg-gray-50 text-sm">{version.title}</h3>
<div className="uhm-wiki-view ql-editor wiki-article h-[70vh] overflow-auto" dangerouslySetInnerHTML={{ __html: version.content }} />
</div>
))}
</div>
</div>
)}
</main>
{viewMode !== 'compare' && (
<aside className="hidden lg:block self-start sticky top-6">
{viewMode === 'read' && toc.length > 0 && (
<div className="border border-gray-300 bg-[#f8f9fa] p-3 rounded-sm text-sm mb-6">
<p className="font-bold text-center mb-2">Mục lục</p>
<nav>
<div className="grid gap-1 w-full overflow-auto">
{toc.map((t) => { {toc.map((t) => {
const pad = Math.max(0, Math.min(5, t.level - 1)) * 10; const pad = Math.max(0, Math.min(5, t.level - 1)) * 12;
const isActive = activeHeadingId === t.id; const isActive = activeHeadingId === t.id;
return ( return (
<a <a key={t.id} href={`#${t.id}`} className={`block py-0.5 text-xs leading-5 transition break-words ${isActive ? "font-bold" : "text-blue-600 hover:underline"}`} style={{ paddingLeft: pad }} title={t.text}>
key={t.id} <span className="mr-1">{t.level}.</span>{t.text}
href={`#${t.id}`}
className={`rounded-md px-2 py-1 text-xs leading-5 transition ${
isActive
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300"
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-white/[0.04]"
}`}
style={{ paddingLeft: 8 + pad }}
title={t.text}
>
{t.text}
</a> </a>
); );
})} })}
</div> </div>
</nav> </nav>
</div>
)} )}
<div className="mt-4 border-t border-gray-200 dark:border-gray-800 pt-3"> <div className="border border-gray-300 bg-white rounded-sm text-xs overflow-hidden">
<div className="text-[11px] text-gray-500 dark:text-gray-400 break-all"> <table className="w-full">
Link: {`${typeof window !== "undefined" ? window.location.origin : ""}/wiki/${normalizedSlug}`} <tbody>
</div> <tr className="border-b border-gray-100 last:border-0">
<td className="px-2 py-2 font-normal text-gray-500 w-1/5">Slug</td>
<td className="px-2 py-2 text-gray-900 break-all">{normalizedSlug || "-"}</td>
</tr>
<tr className="border-b border-gray-100 last:border-0">
<td className="px-2 py-2 font-normal text-gray-500">ID</td>
<td className="px-2 py-2 text-gray-900">{wiki.id || "-"}</td>
</tr>
<tr className="border-b border-gray-100 last:border-0">
<td className="px-2 py-2 font-normal text-gray-500">Dự án</td>
<td className="px-2 py-2 text-gray-900">{wiki.project_id || "-"}</td>
</tr>
<tr className="border-b border-gray-100 last:border-0">
<td className="px-2 py-2 font-normal text-gray-500">Tạo lúc</td>
<td className="px-2 py-2 text-gray-900">{formatDate(wiki.created_at)}</td>
</tr>
<tr>
<td className="pr-1 pl-2 py-2 font-normal text-gray-500">Cập nhật</td>
<td className="px-2 py-2 text-gray-900">{formatDate(wiki.updated_at)}</td>
</tr>
</tbody>
</table>
</div> </div>
</aside> </aside>
)}
<main className="min-w-0">
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
<div
ref={contentRootRef}
className="uhm-wiki-view ql-editor text-sm text-gray-900 dark:text-gray-100"
dangerouslySetInnerHTML={{ __html: renderHtml }}
/>
</div>
</main>
</div> </div>
</>
)} )}
</div> </div>
@@ -583,107 +728,94 @@ export default function WikiBySlugClient({ slug }: { slug: string }) {
) : null} ) : null}
<style jsx global>{` <style jsx global>{`
/* Quill view container tweaks: allow page-level scrolling instead of inner scroll. */ .wiki-article {
line-height: 1.6;
font-size: 1em;
padding: 18px 20px;
}
.uhm-wiki-view.ql-editor { .uhm-wiki-view.ql-editor {
height: auto; height: auto;
overflow-y: visible; overflow-y: visible;
padding: 18px 20px;
} }
/* Improve readability for view mode (Quill resets block margins to 0). */ .wiki-article p {
.uhm-wiki-view.ql-editor p {
margin: 0 0 0.75em; margin: 0 0 0.75em;
} }
.uhm-wiki-view.ql-editor h1 { .wiki-article h1,
margin: 1.25em 0 0.6em; .wiki-article h2,
font-size: 1.9em; .wiki-article h3,
font-weight: 800; .wiki-article h4,
.wiki-article h5,
.wiki-article h6 {
font-weight: normal;
margin: 0.8em 0 0.3em;
padding-bottom: 0.1em;
border-bottom: 1px solid #a2a9b1;
scroll-margin-top: 16px;
}
.wiki-article h1 {
font-size: 1.8em;
line-height: 1.2; line-height: 1.2;
} }
.uhm-wiki-view.ql-editor h2 { .wiki-article h2 {
margin: 1.15em 0 0.55em; font-size: 1.5em;
font-size: 1.55em;
font-weight: 800;
line-height: 1.25; line-height: 1.25;
margin-top: 1.4em;
} }
.uhm-wiki-view.ql-editor h3 { .wiki-article h3 {
margin: 1.05em 0 0.5em;
font-size: 1.25em; font-size: 1.25em;
font-weight: 700;
line-height: 1.3; line-height: 1.3;
} }
.uhm-wiki-view.ql-editor h4, .wiki-article h4,
.uhm-wiki-view.ql-editor h5, .wiki-article h5,
.uhm-wiki-view.ql-editor h6 { .wiki-article h6 {
margin: 0.95em 0 0.45em;
font-size: 1.05em; font-size: 1.05em;
font-weight: 700;
line-height: 1.35; line-height: 1.35;
} }
.uhm-wiki-view.ql-editor ul, .wiki-article ul,
.uhm-wiki-view.ql-editor ol { .wiki-article ol {
margin: 0 0 0.75em; margin: 0 0 0.75em;
padding-left: 1.5em; padding-left: 1.5em;
} }
.uhm-wiki-view.ql-editor blockquote { .wiki-article blockquote {
margin: 0 0 0.75em; margin: 0 0 0.75em;
padding-left: 12px; padding-left: 12px;
border-left: 3px solid rgba(148, 163, 184, 0.6); border-left: 3px solid #a2a9b1;
color: rgba(71, 85, 105, 1); color: #202122;
} }
:is(.dark *) .uhm-wiki-view.ql-editor blockquote { .wiki-article pre {
border-left-color: rgba(100, 116, 139, 0.6);
color: rgba(203, 213, 225, 0.95);
}
.uhm-wiki-view.ql-editor pre {
margin: 0 0 0.75em; margin: 0 0 0.75em;
padding: 12px 14px; padding: 12px 14px;
border: 1px solid rgba(226, 232, 240, 1); border: 1px solid #a2a9b1;
border-radius: 10px; border-radius: 10px;
background: rgba(248, 250, 252, 1); background: #f8f9fa;
overflow: auto; overflow: auto;
font-family: monospace;
} }
:is(.dark *) .uhm-wiki-view.ql-editor pre { .wiki-article img {
border-color: rgba(51, 65, 85, 1);
background: rgba(2, 6, 23, 0.4);
}
.uhm-wiki-view.ql-editor img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
border-radius: 8px; border-radius: 8px;
} }
.uhm-wiki-view.ql-editor h1, .wiki-article a {
.uhm-wiki-view.ql-editor h2, text-decoration: none;
.uhm-wiki-view.ql-editor h3,
.uhm-wiki-view.ql-editor h4,
.uhm-wiki-view.ql-editor h5,
.uhm-wiki-view.ql-editor h6 {
scroll-margin-top: 16px;
} }
.uhm-wiki-view.ql-editor a { .wiki-article a[href]:not([href=""]):not([href="__missing__"]) {
color: #3366cc;
}
.wiki-article a[href]:not([href=""]):not([href="__missing__"]):hover {
text-decoration: underline; text-decoration: underline;
text-decoration-thickness: from-font;
text-underline-offset: 2px;
} }
.uhm-wiki-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) { .wiki-article a[href="__missing__"] {
color: #2563eb;
}
:is(.dark *) .uhm-wiki-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
color: #60a5fa;
}
.uhm-wiki-view.ql-editor a[href="__missing__"] {
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
} }
.uhm-wiki-view.ql-editor a:not([href]), .wiki-article a:not([href]),
.uhm-wiki-view.ql-editor a[href=""], .wiki-article a[href=""],
.uhm-wiki-view.ql-editor a[href="__missing__"] { .wiki-article a[href="__missing__"] {
color: #dc2626; color: #dc2626;
} }
:is(.dark *) .uhm-wiki-view.ql-editor a:not([href]),
:is(.dark *) .uhm-wiki-view.ql-editor a[href=""],
:is(.dark *) .uhm-wiki-view.ql-editor a[href="__missing__"] {
color: #f87171;
}
`}</style> `}</style>
</div> </div>
); );
+6 -10
View File
@@ -1,6 +1,5 @@
export type StoredTokens = { export type StoredTokens = {
access_token: string; access_token: string;
refresh_token: string;
}; };
const LS_KEY = "uhm_auth_tokens_v1"; const LS_KEY = "uhm_auth_tokens_v1";
@@ -12,9 +11,9 @@ function safeParseTokens(raw: string | null): StoredTokens | null {
try { try {
const v = JSON.parse(raw) as Partial<StoredTokens>; const v = JSON.parse(raw) as Partial<StoredTokens>;
if (!v || typeof v !== "object") return null; if (!v || typeof v !== "object") return null;
if (typeof v.access_token !== "string" || typeof v.refresh_token !== "string") return null; if (typeof v.access_token !== "string") return null;
if (!v.access_token.trim() || !v.refresh_token.trim()) return null; if (!v.access_token.trim()) return null;
return { access_token: v.access_token, refresh_token: v.refresh_token }; return { access_token: v.access_token };
} catch { } catch {
return null; return null;
} }
@@ -41,10 +40,6 @@ export function getAccessToken(): string | null {
return getStoredTokens()?.access_token ?? null; return getStoredTokens()?.access_token ?? null;
} }
export function getRefreshToken(): string | null {
return getStoredTokens()?.refresh_token ?? null;
}
export function clearStoredTokens(): void { export function clearStoredTokens(): void {
setStoredTokens(null); setStoredTokens(null);
} }
@@ -64,6 +59,7 @@ export function extractTokensFromResponsePayload(payload: any): StoredTokens | n
tokenContainer?.accessToken ?? tokenContainer?.accessToken ??
tokenContainer?.token ?? tokenContainer?.token ??
tokenContainer?.access ?? tokenContainer?.access ??
tokenContainer?.jwt ??
null; null;
const refresh = const refresh =
@@ -71,8 +67,8 @@ export function extractTokensFromResponsePayload(payload: any): StoredTokens | n
tokenContainer?.refreshToken ?? tokenContainer?.refreshToken ??
tokenContainer?.refresh ?? tokenContainer?.refresh ??
null; null;
if (typeof access === "string" && typeof refresh === "string" && access.trim() && refresh.trim()) { if (typeof access === "string" && access.trim()) {
return { access_token: access, refresh_token: refresh }; return { access_token: access };
} }
return null; return null;
} }
+5 -24
View File
@@ -4,7 +4,6 @@ import {
clearStoredTokens, clearStoredTokens,
extractTokensFromResponsePayload, extractTokensFromResponsePayload,
getAccessToken, getAccessToken,
getRefreshToken,
setStoredTokens, setStoredTokens,
} from "@/auth/tokenStore" } from "@/auth/tokenStore"
@@ -55,14 +54,15 @@ api.interceptors.request.use((config: any) => {
function isAuthTokenExpiredMessage(message: string): boolean { function isAuthTokenExpiredMessage(message: string): boolean {
const normalized = message.trim().toLowerCase() const normalized = message.trim().toLowerCase()
if (!normalized) return false 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 ( return (
normalized.includes("invalid or expired jwt") || normalized.includes("invalid or expired jwt") ||
normalized.includes("jwt expired") || normalized.includes("jwt expired") ||
normalized.includes("token expired") || normalized.includes("token expired") ||
normalized.includes("invalid token") || normalized.includes("invalid token") ||
normalized.includes("expired token") || normalized.includes("expired token") ||
normalized.includes("unauthorized") || normalized.includes("token is invalid") ||
normalized.includes("access denied") ||
normalized.includes("not authenticated") normalized.includes("not authenticated")
) )
} }
@@ -117,31 +117,11 @@ async function performRefreshAndRetry(originalRequest: any): Promise<AxiosRespon
isRefreshing = true isRefreshing = true
try { try {
const refreshToken = getRefreshToken()
const tryHeaderRefresh = async () => {
if (!refreshToken) return null
// Use dedicated refreshApi to handle baseURL and credentials consistently.
return refreshApi.post("/auth/refresh", {}, {
headers: { Authorization: `Bearer ${refreshToken}` }
})
}
const tryCookieRefresh = async () => { const tryCookieRefresh = async () => {
return refreshApi.post("/auth/refresh", {}) return refreshApi.post("/auth/refresh", {})
} }
let refreshRes: any = null let refreshRes: any = await tryCookieRefresh()
try {
refreshRes = (await tryHeaderRefresh()) || (await tryCookieRefresh())
} catch (e: any) {
// If header-based refresh fails (wrong token type), fall back to cookie refresh.
if (refreshToken && e?.response?.status === 401) {
refreshRes = await tryCookieRefresh()
} else {
throw e
}
}
const nextTokens = extractTokensFromResponsePayload(refreshRes?.data) const nextTokens = extractTokensFromResponsePayload(refreshRes?.data)
if (nextTokens) setStoredTokens(nextTokens) if (nextTokens) setStoredTokens(nextTokens)
@@ -158,6 +138,7 @@ async function performRefreshAndRetry(originalRequest: any): Promise<AxiosRespon
} catch (refreshErr: any) { } catch (refreshErr: any) {
processQueue(refreshErr) processQueue(refreshErr)
// Only force logout when refresh token/session is truly invalid (401). // Only force logout when refresh token/session is truly invalid (401).
// CRITICAL: We only redirect if it's a 401, which means the HttpOnly cookie is missing or invalid.
if (refreshErr?.response?.status === 401) { if (refreshErr?.response?.status === 401) {
clearStoredTokens() clearStoredTokens()
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
+1 -2
View File
@@ -4,7 +4,6 @@ import { clearStoredTokens, setStoredTokens } from "@/auth/tokenStore";
export type AuthTokens = { export type AuthTokens = {
access_token: string; access_token: string;
refresh_token: string;
}; };
export type CurrentUser = { export type CurrentUser = {
@@ -21,7 +20,7 @@ export async function signIn(email: string, password: string): Promise<AuthToken
jsonRequestInit("POST", { email, password }), jsonRequestInit("POST", { email, password }),
{ skipAuth: true } { skipAuth: true }
); );
if (res?.access_token && res?.refresh_token) setStoredTokens(res); if (res?.access_token) setStoredTokens(res);
return res; return res;
} }
+1
View File
@@ -9,6 +9,7 @@ export const API_ENDPOINTS = {
geometries: `${API_BASE_URL}/geometries`, geometries: `${API_BASE_URL}/geometries`,
entities: `${API_BASE_URL}/entities`, entities: `${API_BASE_URL}/entities`,
wikis: `${API_BASE_URL}/wikis`, wikis: `${API_BASE_URL}/wikis`,
wikiContent: (id: string) => `${API_BASE_URL}/wikis/content/${id}`,
// New API uses projects + commits + submissions (JWT-protected). // New API uses projects + commits + submissions (JWT-protected).
authSignin: `${API_BASE_URL}/auth/signin`, authSignin: `${API_BASE_URL}/auth/signin`,
authRefresh: `${API_BASE_URL}/auth/refresh`, authRefresh: `${API_BASE_URL}/auth/refresh`,
+31 -2
View File
@@ -8,7 +8,7 @@ export type { GeometriesBBoxQuery } from "@/uhm/types/api";
export type EntityGeometrySearchGeo = { export type EntityGeometrySearchGeo = {
id: string; id: string;
geo_type: number; type: string | null;
draw_geometry: unknown; draw_geometry: unknown;
binding?: unknown; binding?: unknown;
time_start?: number | null; time_start?: number | null;
@@ -27,6 +27,18 @@ export type SearchGeometriesByEntityNameResponse = {
next_cursor?: string; 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 { function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
const query = new URLSearchParams({ const query = new URLSearchParams({
// API mới dùng snake_case // API mới dùng snake_case
@@ -71,7 +83,24 @@ export async function searchGeometriesByEntityName(
params.set("limit", String(Math.trunc(options.limit))); params.set("limit", String(Math.trunc(options.limit)));
} }
return requestJson<SearchGeometriesByEntityNameResponse>(`${API_ENDPOINTS.geometries}/entity?${params.toString()}`); const response = await requestJson<SearchGeometriesByEntityNameApiResponse>(
`${API_ENDPOINTS.geometries}/entity?${params.toString()}`
);
return {
...response,
items: (response.items || []).map((item) => ({
...item,
geometries: (item.geometries || []).map((geometry) => ({
id: geometry.id,
type: geoTypeCodeToTypeKey(geometry.geo_type) || null,
draw_geometry: geometry.draw_geometry,
binding: geometry.binding,
time_start: geometry.time_start ?? null,
time_end: geometry.time_end ?? null,
})),
})),
};
} }
type GeometryRow = { type GeometryRow = {
+3 -1
View File
@@ -1,5 +1,6 @@
import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config"; import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config";
import { ApiError, jsonRequestInit, requestJson } from "@/uhm/api/http"; import { ApiError, jsonRequestInit, requestJson } from "@/uhm/api/http";
import { toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { import type {
CreateCommitInput, CreateCommitInput,
CreateProjectInput, CreateProjectInput,
@@ -76,10 +77,11 @@ export async function createProjectCommit(
input: CreateCommitInput input: CreateCommitInput
): Promise<{ commit: ProjectCommit; state: ProjectState }> { ): Promise<{ commit: ProjectCommit; state: ProjectState }> {
// POST /projects/{id}/commits // POST /projects/{id}/commits
const snapshot = toApiEditorSnapshot(input.snapshot);
const commit = await requestJson<ProjectCommit>( const commit = await requestJson<ProjectCommit>(
`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits`, `${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits`,
jsonRequestInit("POST", { jsonRequestInit("POST", {
snapshot_json: input.snapshot, snapshot_json: snapshot,
edit_summary: input.edit_summary, edit_summary: input.edit_summary,
}) })
); );
+11
View File
@@ -1,3 +1,4 @@
import api from "@/config/config";
import { API_ENDPOINTS } from "@/uhm/api/config"; import { API_ENDPOINTS } from "@/uhm/api/config";
import { ApiError, requestJson } from "@/uhm/api/http"; import { ApiError, requestJson } from "@/uhm/api/http";
@@ -10,6 +11,11 @@ export type Wiki = {
is_deleted?: boolean; is_deleted?: boolean;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
content_sample?:{
created_at?: string;
content?: string;
id?: string;
}[];
}; };
export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> { export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise<Wiki[]> {
@@ -60,3 +66,8 @@ export async function checkWikiSlugExists(slug: string): Promise<boolean> {
// Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs. // Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs.
return true; return true;
} }
export const getContentByVersionWikiId = async (id: string) => {
const response = await api.get(API_ENDPOINTS.wikiContent(id));
return response?.data;
};
-18
View File
@@ -9,7 +9,6 @@ import { ToolsPanel } from "./editor/ToolsPanel";
import { CommitPanel } from "./editor/CommitPanel"; import { CommitPanel } from "./editor/CommitPanel";
import { CommitHistoryPanel } from "./editor/CommitHistoryPanel"; import { CommitHistoryPanel } from "./editor/CommitHistoryPanel";
import { UndoListPanel } from "./editor/UndoListPanel"; import { UndoListPanel } from "./editor/UndoListPanel";
import { SessionPanel } from "./editor/SessionPanel";
import { SubmitModal } from "./editor/SubmitModal"; import { SubmitModal } from "./editor/SubmitModal";
type Props = { type Props = {
@@ -38,16 +37,6 @@ type Props = {
}>; }>;
changesCount: number; changesCount: number;
undoStack: UndoAction[]; undoStack: UndoAction[];
createdEntities: Array<{
id: string;
name: string;
}>;
createdGeometries: Array<{
id: string | number;
geometryType: string;
semanticType?: string | null;
entityNames: string[];
}>;
width?: number; width?: number;
}; };
@@ -72,8 +61,6 @@ export default function Editor({
commits, commits,
changesCount, changesCount,
undoStack, undoStack,
createdEntities,
createdGeometries,
width = 280, width = 280,
}: Props) { }: Props) {
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false); const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
@@ -159,11 +146,6 @@ export default function Editor({
<UndoListPanel undoStack={undoStack} /> <UndoListPanel undoStack={undoStack} />
<SessionPanel
createdEntities={createdEntities}
createdGeometries={createdGeometries}
/>
<SubmitModal <SubmitModal
isSubmitModalOpen={isSubmitModalOpen} isSubmitModalOpen={isSubmitModalOpen}
submitContent={submitContent} submitContent={submitContent}
+84 -1
View File
@@ -26,6 +26,7 @@ type MapProps = {
geometryVisibility?: Record<string, boolean>; geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[]; selectedFeatureIds: (string | number)[];
onSelectFeatureIds: (ids: (string | number)[]) => void; onSelectFeatureIds: (ids: (string | number)[]) => void;
onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
labelContextDraft?: FeatureCollection; labelContextDraft?: FeatureCollection;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number) => void; onDeleteFeature?: (id: string | number) => void;
@@ -40,10 +41,13 @@ type MapProps = {
focusFeatureCollection?: FeatureCollection | null; focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null; focusRequestKey?: string | number | null;
focusPadding?: number | import("maplibre-gl").PaddingOptions; focusPadding?: number | import("maplibre-gl").PaddingOptions;
hideOutside?: boolean;
onToggleHideOutside?: () => void;
}; };
export default function Map({ export default function Map({
mode, mode,
onSetMode,
draft, draft,
backgroundVisibility, backgroundVisibility,
geometryVisibility, geometryVisibility,
@@ -63,10 +67,13 @@ export default function Map({
focusFeatureCollection = null, focusFeatureCollection = null,
focusRequestKey = null, focusRequestKey = null,
focusPadding, focusPadding,
hideOutside = false,
onToggleHideOutside,
}: MapProps) { }: MapProps) {
const modeRef = useRef<MapProps["mode"]>(mode); const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft); const draftRef = useRef<FeatureCollection>(draft);
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds); const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
const onSetModeRef = useRef(onSetMode);
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange); const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature); const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature); const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
@@ -75,6 +82,7 @@ export default function Map({
useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { modeRef.current = mode; }, [mode]);
useEffect(() => { draftRef.current = draft; }, [draft]); useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]); useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
@@ -106,6 +114,7 @@ export default function Map({
allowGeometryEditing, allowGeometryEditing,
selectedFeatureIds, selectedFeatureIds,
onSelectFeatureIdsRef, onSelectFeatureIdsRef,
onSetModeRef,
onCreateRef, onCreateRef,
onDeleteRef, onDeleteRef,
onUpdateRef, onUpdateRef,
@@ -150,6 +159,14 @@ export default function Map({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMapLoaded]); }, [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]);
return ( return (
<div style={{ width: "100%", height, position: "relative" }}> <div style={{ width: "100%", height, position: "relative" }}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} /> <div ref={containerRef} style={{ width: "100%", height: "100%" }} />
@@ -198,7 +215,7 @@ export default function Map({
> >
<div <div
style={{ style={{
maxWidth: "520px", maxWidth: "650px",
margin: "0 auto", margin: "0 auto",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -212,6 +229,72 @@ export default function Map({
pointerEvents: "auto", pointerEvents: "auto",
}} }}
> >
{mode === "replay" && (
<>
<button
type="button"
onClick={() => onSetMode?.("select")}
style={{
...zoomButtonStyle,
width: "auto",
padding: "0 12px",
fontSize: "12px",
fontWeight: 700,
background: "#7f1d1d",
color: "white",
border: "1px solid #991b1b",
borderRadius: "999px",
cursor: "pointer",
marginRight: "4px",
}}
>
Thoát Replay Edit
</button>
<div
onClick={onToggleHideOutside}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
marginRight: "8px",
userSelect: "none",
}}
>
<span style={{ fontSize: "12px", fontWeight: 700, color: hideOutside ? "#fb7185" : "#94a3b8" }}>
Hide Outside
</span>
<div
style={{
width: "32px",
height: "18px",
borderRadius: "10px",
background: hideOutside ? "#e11d48" : "#334155",
position: "relative",
transition: "background 0.2s",
border: "1px solid rgba(255,255,255,0.1)",
}}
>
<div
style={{
position: "absolute",
top: "2px",
left: hideOutside ? "16px" : "2px",
width: "12px",
height: "12px",
borderRadius: "50%",
background: "white",
transition: "left 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
boxShadow: "0 1px 2px rgba(0,0,0,0.3)",
}}
/>
</div>
</div>
<div style={{ width: "1px", height: "20px", background: "rgba(148, 163, 184, 0.3)", marginRight: "4px" }} />
</>
)}
<label <label
title={ title={
isGlobeProjection isGlobeProjection
@@ -3,9 +3,19 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import NewBadge from "@/uhm/components/editor/NewBadge";
type EntityChoice = { id: string; name: string }; type EntityChoice = { id: string; name: string; isNew?: boolean };
type WikiChoice = { id: string; title: string }; 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 = { type Props = {
entities: EntityChoice[]; entities: EntityChoice[];
@@ -28,7 +38,11 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
() => () =>
(wikis || []) (wikis || [])
.filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0) .filter((w) => w && typeof w.id === "string" && w.id.trim().length > 0)
.map((w) => ({ id: w.id, title: wikiTitle(w) })), .map((w) => ({
id: w.id,
title: wikiTitle(w),
isNew: w.source === "inline" && w.operation === "create",
})),
[wikis] [wikis]
); );
@@ -48,6 +62,41 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
return set; return set;
}, [activeEntityId, links]); }, [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) => { const toggle = (wikiId: string) => {
if (!activeEntityId) return; if (!activeEntityId) return;
const id = String(wikiId || "").trim(); const id = String(wikiId || "").trim();
@@ -69,6 +118,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
const activeWikiLinked = activeEntityId && activeWikiId ? activeLinks.has(activeWikiId) : false; const activeWikiLinked = activeEntityId && activeWikiId ? activeLinks.has(activeWikiId) : false;
const activeWikiChoice = activeWikiId ? wikiChoices.find((w) => w.id === activeWikiId) || null : null; const activeWikiChoice = activeWikiId ? wikiChoices.find((w) => w.id === activeWikiId) || null : null;
const activeEntityChoice = activeEntityId ? entityChoices.find((e) => e.id === activeEntityId) || null : null;
return ( return (
<div <div
@@ -82,7 +132,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}>Entity Wiki</div> <div style={{ fontWeight: 700, fontSize: "14px" }}>Entity Wiki</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{links.length}</div> <div style={{ fontSize: "12px", color: "#94a3b8" }}>{activeBindingRows.length}</div>
<button <button
type="button" type="button"
onClick={() => setCollapsed((v) => !v)} onClick={() => setCollapsed((v) => !v)}
@@ -134,8 +184,9 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
</select> </select>
{activeEntityId ? ( {activeEntityId ? (
<ActiveSelectionLabel <ActiveSelectionLabel
label={entityChoices.find((e) => e.id === activeEntityId)?.name || activeEntityId} label={activeEntityChoice?.name || activeEntityId}
id={activeEntityId} id={activeEntityId}
isNew={Boolean(activeEntityChoice?.isNew)}
/> />
) : null} ) : null}
</div> </div>
@@ -173,6 +224,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
<ActiveSelectionLabel <ActiveSelectionLabel
label={activeWikiChoice.title} label={activeWikiChoice.title}
id={activeWikiChoice.id} id={activeWikiChoice.id}
isNew={Boolean(activeWikiChoice.isNew)}
/> />
) : null} ) : null}
@@ -275,6 +327,82 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
)} )}
</div> </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>
)} )}
</div> </div>
@@ -284,9 +412,11 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
function ActiveSelectionLabel({ function ActiveSelectionLabel({
label, label,
id, id,
isNew,
}: { }: {
label: string; label: string;
id: string; id: string;
isNew?: boolean;
}) { }) {
return ( return (
<div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}> <div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
@@ -296,6 +426,7 @@ function ActiveSelectionLabel({
<span style={{ color: "#64748b", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}> <span style={{ color: "#64748b", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{id} {id}
</span> </span>
{isNew ? <NewBadge /> : null}
</div> </div>
); );
} }
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState, type KeyboardEvent } from "react"; import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
import NewBadge from "@/uhm/components/editor/NewBadge"; import NewBadge from "@/uhm/components/editor/NewBadge";
type GeometryChoice = { type GeometryChoice = {
@@ -49,6 +49,16 @@ export default function GeometryBindingPanel({
if (!selectedGeometryId) return null; if (!selectedGeometryId) return null;
return rows.find((g) => g.id === selectedGeometryId) || null; return rows.find((g) => g.id === selectedGeometryId) || null;
}, [rows, selectedGeometryId]); }, [rows, selectedGeometryId]);
const visibleRows = useMemo(() => {
return rows
.filter((g) => g.id !== selectedGeometryId)
.sort((a, b) => {
const aBound = bindingSet.has(a.id);
const bBound = bindingSet.has(b.id);
if (aBound !== bBound) return aBound ? -1 : 1;
return a.id.localeCompare(b.id);
});
}, [bindingSet, rows, selectedGeometryId]);
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => { const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
if (!canFocusGeometry) return; if (!canFocusGeometry) return;
@@ -174,8 +184,7 @@ export default function GeometryBindingPanel({
{collapsed ? null : rows.length ? ( {collapsed ? null : rows.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}> <div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{rows {visibleRows
.filter((g) => g.id !== selectedGeometryId)
.map((g) => { .map((g) => {
const isBound = bindingSet.has(g.id); const isBound = bindingSet.has(g.id);
return ( return (
@@ -184,8 +193,8 @@ export default function GeometryBindingPanel({
style={{ style={{
padding: "8px", padding: "8px",
borderRadius: "6px", borderRadius: "6px",
border: "1px solid #1f2937", border: isBound ? "1px solid rgba(20, 184, 166, 0.65)" : "1px solid #1f2937",
background: "transparent", background: isBound ? "rgba(20, 184, 166, 0.12)" : "transparent",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
@@ -219,6 +228,7 @@ export default function GeometryBindingPanel({
> >
{g.label || g.id} {g.label || g.id}
</span> </span>
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
{g.isNew ? <NewBadge /> : null} {g.isNew ? <NewBadge /> : null}
</div> </div>
<div <div
@@ -283,6 +293,24 @@ export default function GeometryBindingPanel({
); );
} }
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() { function LockIcon() {
return ( return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+7
View File
@@ -36,5 +36,12 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
</div> </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>
)
}
return null; 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,
};
@@ -39,6 +39,22 @@ export default function ProjectEntityRefsPanel({
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [activeEntityId, setActiveEntityId] = useState<string | null>(null); const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const selectedEntityIdSet = useMemo(
() => new Set((selectedGeometryEntityIds || []).map(String)),
[selectedGeometryEntityIds]
);
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( const activeEntity = useMemo(
() => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null), () => (activeEntityId ? entityRefs.find((e) => String(e.id) === String(activeEntityId)) || null : null),
@@ -90,16 +106,24 @@ export default function ProjectEntityRefsPanel({
</div> </div>
</div> </div>
{collapsed ? null : entityRefs.length ? ( {collapsed ? null : sortedEntityRefs.length ? (
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}> <div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
{entityRefs.map((e) => ( {sortedEntityRefs.map((e) => {
const entityId = String(e.id);
const isBoundToSelectedGeometry = selectedEntityIdSet.has(entityId);
const isActive = activeEntityId === entityId;
return (
<div <div
key={e.id} key={e.id}
style={{ style={{
padding: "8px", padding: "8px",
borderRadius: "6px", borderRadius: "6px",
border: activeEntityId === String(e.id) ? "1px solid #2563eb" : "1px solid #1f2937", border: isActive
background: "transparent", ? "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", display: "flex",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
@@ -124,6 +148,7 @@ export default function ProjectEntityRefsPanel({
<span style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}> <span style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.name || e.id} {e.name || e.id}
</span> </span>
{isBoundToSelectedGeometry ? <span style={boundBadgeStyle}>bound</span> : null}
{isNewEntityRef(e) ? <NewBadge /> : null} {isNewEntityRef(e) ? <NewBadge /> : null}
</div> </div>
<div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}> <div style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
@@ -133,11 +158,11 @@ export default function ProjectEntityRefsPanel({
{canBindToggle ? ( {canBindToggle ? (
<button <button
type="button" type="button"
title={selectedGeometryEntityIds!.includes(String(e.id)) ? "Unbind from selected geometry" : "Bind to selected geometry"} title={isBoundToSelectedGeometry ? "Unbind from selected geometry" : "Bind to selected geometry"}
onClick={() => onClick={() =>
onToggleBindEntityForSelectedGeometry!( onToggleBindEntityForSelectedGeometry!(
String(e.id), entityId,
!selectedGeometryEntityIds!.includes(String(e.id)) !isBoundToSelectedGeometry
) )
} }
style={{ style={{
@@ -153,12 +178,12 @@ export default function ProjectEntityRefsPanel({
flex: "0 0 auto", flex: "0 0 auto",
}} }}
aria-label={ aria-label={
selectedGeometryEntityIds!.includes(String(e.id)) isBoundToSelectedGeometry
? `Unbind entity ${String(e.id)} from selected geometry` ? `Unbind entity ${entityId} from selected geometry`
: `Bind entity ${String(e.id)} to selected geometry` : `Bind entity ${entityId} to selected geometry`
} }
> >
{selectedGeometryEntityIds!.includes(String(e.id)) ? ( {isBoundToSelectedGeometry ? (
<UnlockIcon /> <UnlockIcon />
) : ( ) : (
<LockIcon /> <LockIcon />
@@ -166,7 +191,8 @@ export default function ProjectEntityRefsPanel({
</button> </button>
) : null} ) : null}
</div> </div>
))} );
})}
</div> </div>
) : ( ) : (
@@ -358,6 +384,24 @@ const entityInputStyle: CSSProperties = {
fontSize: "13px", fontSize: "13px",
}; };
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() { function LockIcon() {
return ( return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -1,7 +1,6 @@
"use client"; "use client";
import { type CSSProperties, useMemo, useState } from "react"; import { type CSSProperties, useMemo, useState } from "react";
import { Entity } from "@/uhm/api/entities";
import { Feature } from "@/uhm/lib/editor/state/useEditorState"; import { Feature } from "@/uhm/lib/editor/state/useEditorState";
import { import {
GeometryPreset, GeometryPreset,
@@ -10,15 +9,11 @@ import {
findGeometryTypeOption, findGeometryTypeOption,
groupGeometryTypeOptions, groupGeometryTypeOptions,
} from "@/uhm/lib/map/geo/geometryTypeOptions"; } from "@/uhm/lib/map/geo/geometryTypeOptions";
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes"; import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
type Props = { type Props = {
selectedFeatures: Feature[]; selectedFeatures: Feature[];
selectedFeatureEntitySummary: string;
selectedFeatureBindingSummary: string;
entities: Entity[];
selectedGeometryEntityIds: string[];
onEntityIdsChange: (values: string[]) => void;
entityTypeOptions: GeometryTypeOption[]; entityTypeOptions: GeometryTypeOption[];
geometryMetaForm: GeometryMetaFormState; geometryMetaForm: GeometryMetaFormState;
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void; onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
@@ -29,11 +24,6 @@ type Props = {
export default function SelectedGeometryPanel({ export default function SelectedGeometryPanel({
selectedFeatures, selectedFeatures,
selectedFeatureEntitySummary,
selectedFeatureBindingSummary,
entities,
selectedGeometryEntityIds,
onEntityIdsChange,
entityTypeOptions, entityTypeOptions,
geometryMetaForm, geometryMetaForm,
onGeometryMetaFormChange, onGeometryMetaFormChange,
@@ -103,7 +93,7 @@ export default function SelectedGeometryPanel({
> >
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: "8px" }}>
<div style={{ fontWeight: 700, fontSize: "14px" }}> <div style={{ fontWeight: 700, fontSize: "14px" }}>
Entity & Geometry Geometry property
</div> </div>
<button <button
type="button" type="button"
@@ -130,67 +120,6 @@ export default function SelectedGeometryPanel({
{collapsed ? null : ( {collapsed ? null : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}> <div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ color: "#e2e8f0" }}>
ID: {selectedFeatures.map(f => String(f.properties.id)).join(", ")}
</div>
<div style={{ color: "#cbd5e1" }}>
Entities hiện tại: {selectedFeatureEntitySummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Binding hiện tại: {selectedFeatureBindingSummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Entities đã chọn:
</div>
{selectedGeometryEntityIds.length ? (
<div style={{ display: "grid", gap: "6px" }}>
{selectedGeometryEntityIds.map((entityId) => {
const entity = entities.find((item) => item.id === entityId) || null;
const label = entity?.name
? `${entity.name} (${entityId})`
: entityId;
return (
<div
key={entityId}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
background: "#111827",
border: "1px solid #334155",
borderRadius: "6px",
padding: "6px 8px",
}}
>
<span style={{ color: "#e2e8f0" }}>{label}</span>
<button
type="button"
onClick={() =>
onEntityIdsChange(
selectedGeometryEntityIds.filter((id) => id !== entityId)
)
}
disabled={isEntitySubmitting}
style={removeButtonStyle}
>
Bỏ
</button>
</div>
);
})}
</div>
) : (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Chưa entity nào đưc gắn.
</div>
)}
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -306,16 +235,6 @@ const entityInputStyle: CSSProperties = {
fontSize: "13px", fontSize: "13px",
}; };
const removeButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "4px 8px",
cursor: "pointer",
background: "#7f1d1d",
color: "#ffffff",
fontSize: "12px",
};
const primaryGeometryButtonStyle: CSSProperties = { const primaryGeometryButtonStyle: CSSProperties = {
border: "none", border: "none",
borderRadius: "6px", borderRadius: "6px",
@@ -370,9 +289,7 @@ function normalizeGeometryPreset(value: unknown): GeometryPreset | null {
} }
function normalizeTypeId(value: unknown): string | null { function normalizeTypeId(value: unknown): string | null {
if (typeof value !== "string") return null; return normalizeGeoTypeKey(value);
const normalized = value.trim().toLowerCase();
return normalized.length ? normalized : null;
} }
function mapGeometryTypeToPreset( function mapGeometryTypeToPreset(
@@ -404,11 +321,3 @@ function getAllowedGroupIdsForPreset(
return ["polygon"]; return ["polygon"];
} }
function formatGeometryPresetLabel(preset: GeometryPreset | null): string {
if (preset === "point") return "point - Điểm";
if (preset === "line") return "line - Tuyến";
if (preset === "circle-area") return "circle - Tròn";
if (preset === "polygon") return "polygon - Đa giác";
return "unknown";
}
@@ -1,62 +0,0 @@
import { Panel } from "./Panel";
type SessionPanelProps = {
createdEntities: Array<{
id: string;
name: string;
}>;
createdGeometries: Array<{
id: string | number;
geometryType: string;
semanticType?: string | null;
entityNames: string[];
}>;
};
export function SessionPanel({
createdEntities,
createdGeometries,
}: SessionPanelProps) {
return (
<Panel title="This Session" defaultOpen={false}>
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
Entities ({createdEntities.length})
</div>
{createdEntities.length === 0 ? (
<div style={{ color: "#64748b", fontSize: 12, marginBottom: 10 }}>Chưa tạo entity mới</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12, marginBottom: 10 }}>
{createdEntities.map((entity) => (
<li
key={entity.id}
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
title={entity.id}
>
{entity.name}
</li>
))}
</ul>
)}
<div style={{ fontSize: 13, color: "#cbd5e1", marginBottom: 6 }}>
Geometries mới chưa commit ({createdGeometries.length})
</div>
{createdGeometries.length === 0 ? (
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa geometry mới chờ commit</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
{createdGeometries.map((geometry) => (
<li
key={String(geometry.id)}
style={{ padding: "6px 0", borderBottom: "1px solid #1f2937", color: "#e2e8f0" }}
>
#{geometry.id} [{geometry.geometryType}]{" "}
{geometry.semanticType ? `- ${geometry.semanticType}` : ""}
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
</li>
))}
</ul>
)}
</Panel>
);
}
@@ -48,6 +48,7 @@ export function formatUndoLabel(action: UndoAction) {
case "snapshot_entities": case "snapshot_entities":
case "snapshot_wikis": case "snapshot_wikis":
case "snapshot_entity_wiki": case "snapshot_entity_wiki":
case "group":
return action.label; return action.label;
default: default:
return "Tác vụ"; return "Tác vụ";
+27 -13
View File
@@ -13,6 +13,7 @@ import {
import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style"; import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style";
import { getRasterTileTemplateUrl } from "@/uhm/api/tiles"; import { getRasterTileTemplateUrl } from "@/uhm/api/tiles";
import { newId } from "@/uhm/lib/utils/id"; import { newId } from "@/uhm/lib/utils/id";
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
type Coordinate = [number, number]; type Coordinate = [number, number];
type PolygonCoordinates = Coordinate[][]; type PolygonCoordinates = Coordinate[][];
@@ -155,8 +156,13 @@ export function filterDraftByGeometryVisibility(
return { return {
...fc, ...fc,
features: fc.features.filter((feature) => { features: fc.features.filter((feature) => {
const id = String(feature.properties.id);
// Kiểm tra ẩn theo ID cụ thể (ưu tiên cao nhất)
if (visibility[id] === false) return false;
const key = getFeatureSemanticType(feature); const key = getFeatureSemanticType(feature);
if (!key) return true; if (!key) return true;
// Kiểm tra ẩn theo loại (semantic type)
return visibility[key] !== false; return visibility[key] !== false;
}), }),
}; };
@@ -342,18 +348,22 @@ export function collectCoordinatePairs(value: unknown): Array<[number, number]>
} }
export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection { export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
const features = fc.features const features: Feature[] = [];
.map((feature) => {
if (!isPathFeature(feature) || feature.geometry.type !== "LineString") return null; for (const feature of fc.features) {
const geometry = buildPathArrowGeometry(feature.geometry.coordinates); if (!isPathFeature(feature)) continue;
if (!geometry) return null;
return { const coordinateGroups = getLineCoordinateGroups(feature.geometry);
type: "Feature" as const, for (const coordinates of coordinateGroups) {
const geometry = buildPathArrowGeometry(coordinates);
if (!geometry) continue;
features.push({
type: "Feature",
properties: { ...feature.properties }, properties: { ...feature.properties },
geometry, geometry,
}; });
}) }
.filter((feature): feature is Feature => feature !== null); }
return { return {
type: "FeatureCollection", type: "FeatureCollection",
@@ -368,9 +378,7 @@ export function isPathFeature(feature: Feature): boolean {
export function getFeatureSemanticType(feature: Feature): string | null { export function getFeatureSemanticType(feature: Feature): string | null {
const value = feature.properties.type || feature.properties.entity_type_id || null; const value = feature.properties.type || feature.properties.entity_type_id || null;
if (!value) return null; return normalizeGeoTypeKey(value);
const normalized = String(value).trim().toLowerCase();
return normalized.length ? normalized : null;
} }
export function buildPathArrowGeometry(coords: [number, number][]): Geometry | null { export function buildPathArrowGeometry(coords: [number, number][]): Geometry | null {
@@ -719,6 +727,12 @@ function isLineGeometry(geometry: Geometry): boolean {
return geometry.type === "LineString" || geometry.type === "MultiLineString"; return geometry.type === "LineString" || geometry.type === "MultiLineString";
} }
function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] {
if (geometry.type === "LineString") return [geometry.coordinates];
if (geometry.type === "MultiLineString") return geometry.coordinates;
return [];
}
function getPolygonLabelPoint(geometry: Geometry): Coordinate | null { function getPolygonLabelPoint(geometry: Geometry): Coordinate | null {
if (geometry.type === "Polygon") { if (geometry.type === "Polygon") {
return getPolygonLabelCandidate(geometry.coordinates)?.point || null; return getPolygonLabelCandidate(geometry.coordinates)?.point || null;
+5 -1
View File
@@ -26,6 +26,7 @@ type UseMapInteractionProps = {
allowGeometryEditing: boolean; allowGeometryEditing: boolean;
selectedFeatureIds: (string | number)[]; selectedFeatureIds: (string | number)[];
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>; onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
onSetModeRef: React.MutableRefObject<((mode: EditorMode) => void) | undefined>;
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>; onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>; onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>; onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
@@ -40,6 +41,7 @@ export function useMapInteraction({
allowGeometryEditing, allowGeometryEditing,
selectedFeatureIds, selectedFeatureIds,
onSelectFeatureIdsRef, onSelectFeatureIdsRef,
onSetModeRef,
onCreateRef, onCreateRef,
onDeleteRef, onDeleteRef,
onUpdateRef, onUpdateRef,
@@ -142,7 +144,8 @@ export function useMapInteraction({
editingEngineRef.current?.beginEditing((originalFeature || feature) as any); editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
} }
: undefined, : undefined,
(ids) => onSelectFeatureIdsRef.current?.(ids) (ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id)
); );
const cleanupPoint = initPoint( const cleanupPoint = initPoint(
@@ -236,6 +239,7 @@ export function useMapInteraction({
engineBindingsRef.current = { engineBindingsRef.current = {
draw: drawingEngine, draw: drawingEngine,
select: selectEngine, select: selectEngine,
replay: selectEngine,
"add-line": lineEngine, "add-line": lineEngine,
"add-path": pathEngine, "add-path": pathEngine,
"add-circle": circleEngine, "add-circle": circleEngine,
+11 -3
View File
@@ -11,7 +11,7 @@ import {
} from "@/uhm/lib/map/styles/style"; } from "@/uhm/lib/map/styles/style";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; 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 { 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/geotypes"; import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypeLayers";
import { import {
applyBackgroundLayerVisibility, applyBackgroundLayerVisibility,
buildTypeMatchExpression, buildTypeMatchExpression,
@@ -384,7 +384,11 @@ export function setupMapLayers(
id: "entity-focus-fill", id: "entity-focus-fill",
type: "fill", type: "fill",
source: "entity-focus", source: "entity-focus",
filter: ["==", ["geometry-type"], "Polygon"], filter: [
"any",
["==", ["geometry-type"], "Polygon"],
["==", ["geometry-type"], "MultiPolygon"],
],
paint: { paint: {
"fill-color": "#fde047", "fill-color": "#fde047",
"fill-opacity": 0.2, "fill-opacity": 0.2,
@@ -413,7 +417,11 @@ export function setupMapLayers(
id: "entity-focus-points", id: "entity-focus-points",
type: "circle", type: "circle",
source: "entity-focus", source: "entity-focus",
filter: ["==", ["geometry-type"], "Point"], filter: [
"any",
["==", ["geometry-type"], "Point"],
["==", ["geometry-type"], "MultiPoint"],
],
paint: { paint: {
"circle-color": "#f8fafc", "circle-color": "#f8fafc",
"circle-radius": 8, "circle-radius": 8,
@@ -28,6 +28,7 @@ type QuillLike = {
type QuillModule = { type QuillModule = {
Quill?: { Quill?: {
import?: (path: string) => unknown; import?: (path: string) => unknown;
register?: (pathOrModule: unknown, moduleOrOverwrite?: unknown, overwrite?: boolean) => void;
}; };
}; };
type QuillLinkFormat = { type QuillLinkFormat = {
@@ -109,6 +110,43 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
const mod = await import("react-quill-new") as QuillModule; const mod = await import("react-quill-new") as QuillModule;
const Quill = mod?.Quill; const Quill = mod?.Quill;
if (!Quill) return; if (!Quill) return;
try {
const BlotFormatterModule = await import("quill-blot-formatter");
const BlotFormatter = BlotFormatterModule.default;
// Only register if not already registered to avoid errors in dev/HMR
Quill.register?.("modules/blotFormatter", BlotFormatter, true);
} catch (err) {
console.error("Failed to load quill-blot-formatter", err);
}
const ImageFormat = Quill.import?.("formats/image") as any;
if (ImageFormat) {
class CustomImage extends ImageFormat {
static formats(domNode: Element) {
const formats = ImageFormat.formats(domNode) || {};
if (domNode.hasAttribute("style")) formats.style = domNode.getAttribute("style");
if (domNode.hasAttribute("width")) formats.width = domNode.getAttribute("width");
if (domNode.hasAttribute("height")) formats.height = domNode.getAttribute("height");
if (domNode.hasAttribute("class")) formats.class = domNode.getAttribute("class");
return formats;
}
format(name: string, value: string) {
if (["style", "width", "height", "class"].includes(name)) {
if (value) {
this.domNode.setAttribute(name, value);
} else {
this.domNode.removeAttribute(name);
}
} else {
super.format(name, value);
}
}
}
Quill.register?.(CustomImage, true);
}
const Link = Quill.import?.("formats/link"); const Link = Quill.import?.("formats/link");
if (!Link) return; if (!Link) return;
@@ -537,6 +575,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
}, },
}, },
}, },
blotFormatter: {},
}; };
}, []); }, []);
+2 -1
View File
@@ -18,4 +18,5 @@ export type UndoAction =
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly) // Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] } | { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
| { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] } | { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] }
| { type: "snapshot_entity_wiki"; label: string; prev: EntityWikiLinkSnapshot[] }; | { type: "snapshot_entity_wiki"; label: string; prev: EntityWikiLinkSnapshot[] }
| { type: "group"; label: string; actions: UndoAction[] };
+20 -15
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useRef, useState } from "react";
import type { UndoAction } from "@/uhm/lib/editor/draft/editorTypes"; import type { UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
import { geometryEquals } from "@/uhm/lib/editor/draft/draftDiff"; import { geometryEquals } from "@/uhm/lib/editor/draft/draftDiff";
@@ -10,31 +10,32 @@ export function useUndoStack(options: Options) {
const { applyUndoAction } = options; const { applyUndoAction } = options;
// Stack thao tác undo (append-only, pop khi undo). // Stack thao tác undo (append-only, pop khi undo).
const [undoStack, setUndoStack] = useState<UndoAction[]>([]); const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
const undoStackRef = useRef<UndoAction[]>([]);
const pushUndo = useCallback((action: UndoAction) => { const pushUndo = useCallback((action: UndoAction) => {
setUndoStack((prev) => { const prev = undoStackRef.current;
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
if (isSameUndo(last, action)) return prev; if (isSameUndo(last, action)) return;
return [...prev, action]; const next = [...prev, action];
}); undoStackRef.current = next;
setUndoStack(next);
}, []); }, []);
const undo = useCallback(() => { const undo = useCallback(() => {
let applied = false; const current = undoStackRef.current;
setUndoStack((prev) => { if (!current.length) return;
if (applied) return prev;
if (!prev.length) return prev;
const last = prev[prev.length - 1];
const remaining = prev.slice(0, -1);
applied = true;
const last = current[current.length - 1];
const didApply = applyUndoAction(last); const didApply = applyUndoAction(last);
return didApply ? remaining : prev; if (!didApply) return;
});
const remaining = current.slice(0, -1);
undoStackRef.current = remaining;
setUndoStack(remaining);
}, [applyUndoAction]); }, [applyUndoAction]);
const clearUndo = useCallback(() => { const clearUndo = useCallback(() => {
undoStackRef.current = [];
setUndoStack([]); setUndoStack([]);
}, []); }, []);
@@ -87,6 +88,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>; const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>;
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev); return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
} }
case "group": {
const next = b as Extract<UndoAction, { type: "group" }>;
return a.label === next.label && JSON.stringify(a.actions) === JSON.stringify(next.actions);
}
default: default:
return false; return false;
} }
@@ -9,7 +9,7 @@ import {
openSectionEditor, openSectionEditor,
submitSection, submitSection,
} from "@/uhm/api/projects"; } from "@/uhm/api/projects";
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot"; import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
@@ -110,7 +110,7 @@ export function useProjectCommands(options: Options) {
// Guardrail: commit payload can get large and some deployments reject/close connections for big bodies. // Guardrail: commit payload can get large and some deployments reject/close connections for big bodies.
// When that happens, browsers often surface it as "TypeError: Failed to fetch". // When that happens, browsers often surface it as "TypeError: Failed to fetch".
try { try {
const payloadText = JSON.stringify({ snapshot_json: snapshot, edit_summary: editSummary }); const payloadText = JSON.stringify({ snapshot_json: toApiEditorSnapshot(snapshot), edit_summary: editSummary });
const bytes = typeof Blob !== "undefined" ? new Blob([payloadText]).size : payloadText.length; const bytes = typeof Blob !== "undefined" ? new Blob([payloadText]).size : payloadText.length;
const limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits) const limitBytes = 3_500_000; // ~3.5MB (conservative vs common default body limits)
if (bytes > limitBytes) { if (bytes > limitBytes) {
+2 -1
View File
@@ -7,7 +7,8 @@ export type EditorMode =
| "add-point" | "add-point"
| "add-line" | "add-line"
| "add-path" | "add-path"
| "add-circle"; | "add-circle"
| "replay";
export type TimelineRange = { export type TimelineRange = {
min: number; min: number;
+47 -14
View File
@@ -1,5 +1,5 @@
import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions"; import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions";
import { geoTypeCodeToTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap"; import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geoTypeMap";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshot } from "@/uhm/types/entities";
import type { EntitySnapshotOperation } from "@/uhm/types/entities"; import type { EntitySnapshotOperation } from "@/uhm/types/entities";
@@ -86,12 +86,15 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline"); const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
const rest: UnknownRecord = { ...g }; const rest: UnknownRecord = { ...g };
delete rest.ref; delete rest.ref;
const typeKey = normalizeGeoTypeKey(rest.type) || normalizeGeoTypeKey(rest.geo_type);
delete rest.geo_type;
return { return {
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">), ...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
id, id,
source, source,
operation, operation,
type: typeKey,
}; };
}) })
: undefined; : undefined;
@@ -210,30 +213,39 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
for (const feature of cloned.features) { for (const feature of cloned.features) {
const gid = String(feature.properties.id); const gid = String(feature.properties.id);
const entity_ids = byGeom.get(gid) || []; const entity_ids = byGeom.get(gid) || [];
const p = feature.properties as unknown as UnknownRecord;
const existingTypeKey = normalizeGeoTypeKey(p.type) || normalizeGeoTypeKey(p.entity_type_id);
const fallbackTypeKey = getDefaultTypeIdForFeature(feature);
if (existingTypeKey) p.type = existingTypeKey;
if (entity_ids.length || hasLinks) { if (entity_ids.length || hasLinks) {
const props = feature.properties as unknown as UnknownRecord; p.entity_ids = entity_ids;
props.entity_ids = entity_ids; p.entity_id = entity_ids[0] || null;
props.entity_id = entity_ids[0] || null;
// Generate denormalized names for UI/map usage. // Generate denormalized names for UI/map usage.
const primaryId = entity_ids[0] || null; const primaryId = entity_ids[0] || null;
const primaryName = primaryId ? (entityNameById.get(primaryId) || "") : ""; const primaryName = primaryId ? (entityNameById.get(primaryId) || "") : "";
const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0); const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0);
props.entity_name = primaryName || null; p.entity_name = primaryName || null;
props.entity_names = names; p.entity_names = names;
} }
// Generate geometry metadata onto feature properties (optional in persisted snapshot). // Generate geometry metadata onto feature properties (optional in persisted snapshot).
const geo = geometryById.get(gid) || null; const geo = geometryById.get(gid) || null;
if (geo) { if (geo) {
const p = feature.properties as unknown as UnknownRecord; const geoRecord = geo as unknown as UnknownRecord;
// type (semantic key) is derived from geometries[].type (numeric code in string form). // type can arrive as numeric geo_type, numeric string, or semantic key depending on backend version.
const typeCode = typeof geo.type === "string" && geo.type.trim().length ? Number(geo.type) : NaN; const typeKey = normalizeGeoTypeKey(geoRecord.type)
const typeKey = geoTypeCodeToTypeKey(Number.isFinite(typeCode) ? typeCode : null); || normalizeGeoTypeKey(geoRecord.geo_type)
|| existingTypeKey
|| fallbackTypeKey;
if (typeKey) p.type = typeKey; if (typeKey) p.type = typeKey;
if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding; if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding;
if (typeof geo.time_start === "number") p.time_start = geo.time_start; if (typeof geo.time_start === "number") p.time_start = geo.time_start;
if (typeof geo.time_end === "number") p.time_end = geo.time_end; if (typeof geo.time_end === "number") p.time_end = geo.time_end;
} else if (!existingTypeKey) {
p.type = fallbackTypeKey;
} }
} }
return cloned; return cloned;
@@ -375,14 +387,12 @@ export function buildEditorSnapshot(options: {
? "update" ? "update"
: "reference"; : "reference";
const bbox = getFeatureBBox(feature); const bbox = getFeatureBBox(feature);
const typeKey = feature.properties.type || getDefaultTypeIdForFeature(feature); const typeKey = normalizeGeoTypeKey(feature.properties.type) || getDefaultTypeIdForFeature(feature);
const typeCode = typeKeyToGeoTypeCode(typeKey);
return { return {
id, id,
operation, operation,
source: "inline", source: "inline",
// BE currently expects geometries[].type as a string. We send the geo_type SMALLINT code as a string. type: typeKey,
type: String(typeCode ?? 0),
draw_geometry: feature.geometry, draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature), binding: normalizeFeatureBindingIds(feature),
time_start: feature.properties.time_start ?? null, time_start: feature.properties.time_start ?? null,
@@ -600,6 +610,29 @@ export function buildEditorSnapshot(options: {
}; };
} }
export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
const cloned = JSON.parse(JSON.stringify(snapshot)) as EditorSnapshot;
if (Array.isArray(cloned.geometries)) {
cloned.geometries = cloned.geometries.map((geometry) => {
const row = { ...(geometry as unknown as UnknownRecord) };
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
delete row.geo_type;
if (typeKey) {
const typeCode = typeKeyToGeoTypeCode(typeKey);
row.type = typeCode == null ? null : String(typeCode);
} else if ("type" in row) {
row.type = null;
}
return row as unknown as GeometrySnapshot;
});
}
return cloned;
}
function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] { function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] {
const seen = new Set<string>(); const seen = new Set<string>();
const deduped: GeometryEntitySnapshot[] = []; const deduped: GeometryEntitySnapshot[] = [];
+141 -22
View File
@@ -5,7 +5,7 @@ import type {
FeatureProperties, FeatureProperties,
Geometry, Geometry,
} from "@/uhm/types/geo"; } from "@/uhm/types/geo";
import { buildInitialMap, deepClone, diffDraftToInitial } from "@/uhm/lib/editor/draft/draftDiff"; import { buildInitialMap, deepClone, diffDraftToInitial, geometryEquals } from "@/uhm/lib/editor/draft/draftDiff";
import { useDraftState } from "@/uhm/lib/editor/draft/useDraftState"; import { useDraftState } from "@/uhm/lib/editor/draft/useDraftState";
import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack"; import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes"; import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
@@ -25,6 +25,11 @@ type SnapshotUndoApi = {
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>; setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
}; };
type FeaturePropertiesPatch = {
id: FeatureProperties["id"];
patch: Partial<FeatureProperties>;
};
// State trung tâm của editor: // State trung tâm của editor:
// - draft: dữ liệu nguồn để render UI // - draft: dữ liệu nguồn để render UI
// - changes: map các thay đổi chờ lưu // - changes: map các thay đổi chờ lưu
@@ -86,19 +91,32 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
} }
case "snapshot_entities": { case "snapshot_entities": {
if (!snapshotUndo) return false; if (!snapshotUndo) return false;
snapshotUndo.setSnapshotEntities(deepClone(action.prev)); const prev = deepClone(action.prev);
snapshotUndo.snapshotEntitiesRef.current = prev;
snapshotUndo.setSnapshotEntities(prev);
return true; return true;
} }
case "snapshot_wikis": { case "snapshot_wikis": {
if (!snapshotUndo) return false; if (!snapshotUndo) return false;
snapshotUndo.setSnapshotWikis(deepClone(action.prev)); const prev = deepClone(action.prev);
snapshotUndo.snapshotWikisRef.current = prev;
snapshotUndo.setSnapshotWikis(prev);
return true; return true;
} }
case "snapshot_entity_wiki": { case "snapshot_entity_wiki": {
if (!snapshotUndo) return false; if (!snapshotUndo) return false;
snapshotUndo.setSnapshotEntityWikiLinks(deepClone(action.prev)); const prev = deepClone(action.prev);
snapshotUndo.snapshotEntityWikiLinksRef.current = prev;
snapshotUndo.setSnapshotEntityWikiLinks(prev);
return true; return true;
} }
case "group": {
let applied = true;
for (let i = action.actions.length - 1; i >= 0; i -= 1) {
applied = applyUndoAction(action.actions[i]) && applied;
}
return applied;
}
default: default:
return false; return false;
} }
@@ -129,6 +147,51 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
pushUndo({ type: "create", id: featureClone.properties.id }); pushUndo({ type: "create", id: featureClone.properties.id });
} }
function createFeatureWithSnapshotEntities(
feature: Feature,
nextEntities: SetStateAction<EntitySnapshot[]>,
label = "Import geometry"
) {
const featureClone = deepClone(feature);
const undoActions: UndoAction[] = [];
if (snapshotUndo) {
const prevEntities = snapshotUndo.snapshotEntitiesRef.current || [];
const prevEntitiesClone = deepClone(prevEntities);
const computedEntities = typeof nextEntities === "function"
? (nextEntities as (p: EntitySnapshot[]) => EntitySnapshot[])(prevEntitiesClone)
: nextEntities;
let entitiesChanged = true;
try {
entitiesChanged = JSON.stringify(prevEntities) !== JSON.stringify(computedEntities);
} catch {
entitiesChanged = true;
}
if (entitiesChanged) {
const computedEntitiesClone = deepClone(computedEntities);
undoActions.push({
type: "snapshot_entities",
label: "Cập nhật entities",
prev: prevEntitiesClone,
});
snapshotUndo.snapshotEntitiesRef.current = computedEntitiesClone;
snapshotUndo.setSnapshotEntities(computedEntitiesClone);
}
}
undoActions.push({ type: "create", id: featureClone.properties.id });
pushUndo(
undoActions.length === 1
? undoActions[0]
: { type: "group", label, actions: undoActions }
);
commitDraft({
...draftRef.current,
features: [...draftRef.current.features, featureClone],
});
}
function patchFeatureProperties( function patchFeatureProperties(
id: FeatureProperties["id"], id: FeatureProperties["id"],
patch: Partial<FeatureProperties> patch: Partial<FeatureProperties>
@@ -154,12 +217,63 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
commitDraft({ ...draftRef.current, features: nextFeatures }); commitDraft({ ...draftRef.current, features: nextFeatures });
} }
function patchFeaturePropertiesBatch(
patches: FeaturePropertiesPatch[],
label = "Cập nhật nhiều geometry"
) {
const mergedPatches = new Map<FeatureProperties["id"], Partial<FeatureProperties>>();
for (const item of patches || []) {
if (!item) continue;
const prev = mergedPatches.get(item.id) || {};
mergedPatches.set(item.id, {
...prev,
...deepClone(item.patch),
});
}
if (!mergedPatches.size) return;
const nextFeatures = [...draftRef.current.features];
const undoActions: UndoAction[] = [];
for (const [id, patch] of mergedPatches.entries()) {
const idx = nextFeatures.findIndex((feature) => feature.properties.id === id);
if (idx === -1) continue;
const prevProperties = deepClone(nextFeatures[idx].properties);
const nextProperties = {
...nextFeatures[idx].properties,
...deepClone(patch),
};
if (JSON.stringify(prevProperties) === JSON.stringify(nextProperties)) {
continue;
}
nextFeatures[idx] = {
...nextFeatures[idx],
properties: nextProperties,
};
undoActions.push({ type: "properties", id, prevProperties });
}
if (!undoActions.length) return;
pushUndo(
undoActions.length === 1
? undoActions[0]
: { type: "group", label, actions: undoActions }
);
commitDraft({ ...draftRef.current, features: nextFeatures });
}
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) { function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id); const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return; if (idx === -1) return;
const prevFeature = draftRef.current.features[idx]; const prevFeature = draftRef.current.features[idx];
const prevGeometry = deepClone(prevFeature.geometry); const prevGeometry = deepClone(prevFeature.geometry);
if (geometryEquals(prevGeometry, newGeometry)) {
return;
}
const nextFeatures = [...draftRef.current.features]; const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = { nextFeatures[idx] = {
...prevFeature, ...prevFeature,
@@ -201,20 +315,21 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
label = "Cập nhật entities" label = "Cập nhật entities"
) => { ) => {
if (!snapshotUndo) return; if (!snapshotUndo) return;
snapshotUndo.setSnapshotEntities((prev) => { const prev = snapshotUndo.snapshotEntitiesRef.current || [];
const prevClone = deepClone(prev); const prevClone = deepClone(prev);
const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prev) : next; const computed = typeof next === "function" ? (next as (p: EntitySnapshot[]) => EntitySnapshot[])(prevClone) : next;
let changed = true; let changed = true;
try { try {
changed = JSON.stringify(prev) !== JSON.stringify(computed); changed = JSON.stringify(prev) !== JSON.stringify(computed);
} catch { } catch {
changed = true; changed = true;
} }
if (changed) { if (!changed) return;
const computedClone = deepClone(computed);
pushUndo({ type: "snapshot_entities", label, prev: prevClone }); pushUndo({ type: "snapshot_entities", label, prev: prevClone });
} snapshotUndo.snapshotEntitiesRef.current = computedClone;
return computed; snapshotUndo.setSnapshotEntities(computedClone);
});
}, [pushUndo, snapshotUndo]); }, [pushUndo, snapshotUndo]);
const setSnapshotWikisUndoable = useCallback(( const setSnapshotWikisUndoable = useCallback((
@@ -222,20 +337,21 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
label = "Cập nhật wikis" label = "Cập nhật wikis"
) => { ) => {
if (!snapshotUndo) return; if (!snapshotUndo) return;
snapshotUndo.setSnapshotWikis((prev) => { const prev = snapshotUndo.snapshotWikisRef.current || [];
const prevClone = deepClone(prev); const prevClone = deepClone(prev);
const computed = typeof next === "function" ? (next as (p: WikiSnapshot[]) => WikiSnapshot[])(prev) : next; const computed = typeof next === "function" ? (next as (p: WikiSnapshot[]) => WikiSnapshot[])(prevClone) : next;
let changed = true; let changed = true;
try { try {
changed = JSON.stringify(prev) !== JSON.stringify(computed); changed = JSON.stringify(prev) !== JSON.stringify(computed);
} catch { } catch {
changed = true; changed = true;
} }
if (changed) { if (!changed) return;
const computedClone = deepClone(computed);
pushUndo({ type: "snapshot_wikis", label, prev: prevClone }); pushUndo({ type: "snapshot_wikis", label, prev: prevClone });
} snapshotUndo.snapshotWikisRef.current = computedClone;
return computed; snapshotUndo.setSnapshotWikis(computedClone);
});
}, [pushUndo, snapshotUndo]); }, [pushUndo, snapshotUndo]);
const setSnapshotEntityWikiLinksUndoable = useCallback(( const setSnapshotEntityWikiLinksUndoable = useCallback((
@@ -243,10 +359,10 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
label = "Cập nhật entity-wiki" label = "Cập nhật entity-wiki"
) => { ) => {
if (!snapshotUndo) return; if (!snapshotUndo) return;
snapshotUndo.setSnapshotEntityWikiLinks((prev) => { const prev = snapshotUndo.snapshotEntityWikiLinksRef.current || [];
const prevClone = deepClone(prev); const prevClone = deepClone(prev);
const computed = typeof next === "function" const computed = typeof next === "function"
? (next as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prev) ? (next as (p: EntityWikiLinkSnapshot[]) => EntityWikiLinkSnapshot[])(prevClone)
: next; : next;
let changed = true; let changed = true;
try { try {
@@ -254,11 +370,12 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
} catch { } catch {
changed = true; changed = true;
} }
if (changed) { if (!changed) return;
const computedClone = deepClone(computed);
pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone }); pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone });
} snapshotUndo.snapshotEntityWikiLinksRef.current = computedClone;
return computed; snapshotUndo.setSnapshotEntityWikiLinks(computedClone);
});
}, [pushUndo, snapshotUndo]); }, [pushUndo, snapshotUndo]);
return { return {
@@ -267,7 +384,9 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
undoStack, undoStack,
changeCount, changeCount,
createFeature, createFeature,
createFeatureWithSnapshotEntities,
patchFeatureProperties, patchFeatureProperties,
patchFeaturePropertiesBatch,
updateFeature, updateFeature,
deleteFeature, deleteFeature,
undo, undo,
+3 -83
View File
@@ -1,8 +1,8 @@
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes"; import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
import { buildCircleRing, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
const EARTH_RADIUS_METERS = 6371008.8;
const CIRCLE_SEGMENTS = 72; const CIRCLE_SEGMENTS = 72;
const MIN_RADIUS_METERS = 1; const MIN_RADIUS_METERS = 1;
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = { const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
@@ -123,6 +123,8 @@ export function initCircle(
onComplete({ onComplete({
type: "Polygon", type: "Polygon",
coordinates: [ring], coordinates: [ring],
circle_center: center,
circle_radius: radiusMeters,
}); });
resetDrawingState(); resetDrawingState();
}; };
@@ -163,85 +165,3 @@ export function initCircle(
cancel: resetDrawingState, cancel: resetDrawingState,
}; };
} }
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
function buildCircleRing(
center: [number, number],
radiusMeters: number,
segments: number
): [number, number][] {
const ring: [number, number][] = [];
for (let i = 0; i <= segments; i += 1) {
const bearingDeg = (i / segments) * 360; // Chia đều 360 do quanh tâm để tạo các điểm trên vòng tròn.
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
}
return ring;
}
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
function distanceMeters(a: [number, number], b: [number, number]): number {
const lat1 = toRad(a[1]);
const lat2 = toRad(b[1]);
const dLat = lat2 - lat1; // Delta vĩ độ (radian).
const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian).
const sinLat = Math.sin(dLat / 2); // Thành phần sin(dLat/2) của công thức Haversine.
const sinLng = Math.sin(dLng / 2); // Thành phần sin(dLng/2) của công thức Haversine.
const h =
sinLat * sinLat +
Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; // h = haversine(d/R), độ lớn cung tròn chuẩn hóa.
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); // Góc tâm (radian) giữa hai điểm trên mặt cầu.
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
}
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
function destinationPoint(
center: [number, number],
distance: number,
bearingDeg: number
): [number, number] {
const lat1 = toRad(center[1]);
const lng1 = toRad(center[0]);
const bearing = toRad(bearingDeg);
const angularDistance = distance / EARTH_RADIUS_METERS; // d/R: khoảng cách góc trên mặt cầu.
const sinLat1 = Math.sin(lat1);
const cosLat1 = Math.cos(lat1);
const sinAngular = Math.sin(angularDistance);
const cosAngular = Math.cos(angularDistance);
const sinLat2 =
sinLat1 * cosAngular +
cosLat1 * sinAngular * Math.cos(bearing); // Công thức vĩ độ điểm đích theo great-circle.
const lat2 = Math.asin(clamp(sinLat2, -1, 1)); // Kẹp [-1,1] để tránh sai số số học trước khi asin.
const y = Math.sin(bearing) * sinAngular * cosLat1; // Tử số atan2 cho biến thiên kinh độ.
const x = cosAngular - sinLat1 * Math.sin(lat2); // Mẫu số atan2 cho biến thiên kinh độ.
const lng2 = lng1 + Math.atan2(y, x); // Kinh độ đích = kinh độ gốc + delta kinh độ.
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
}
// Chuẩn hóa kinh độ về miền [-180, 180].
function normalizeLng(lng: number): number {
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
if (normalized === -180) normalized = 180;
return normalized;
}
// Kẹp giá trị trong đoạn [min, max].
function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
// Đổi đơn vị góc từ độ sang radian.
function toRad(value: number): number {
return (value * Math.PI) / 180; // Đổi độ sang radian.
}
// Đổi đơn vị góc từ radian sang độ.
function toDeg(value: number): number {
return (value * 180) / Math.PI; // Đổi radian sang độ.
}
+79 -7
View File
@@ -1,10 +1,14 @@
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { buildCircleRing, destinationPoint, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
export type EditingHandle = { export type EditingHandle = {
id: string | number; id: string | number;
ring: [number, number][]; ring: [number, number][];
original: Geometry; original: Geometry;
isCircle?: boolean;
circleCenter?: [number, number];
circleRadius?: number;
}; };
export type EditingAPI = { export type EditingAPI = {
@@ -40,8 +44,13 @@ export function createEditingEngine(options: {
const map = mapRef.current; const map = mapRef.current;
if (!editing || !map) return; if (!editing || !map) return;
const closedRing = [...editing.ring, editing.ring[0]]; let shape: GeoJSON.FeatureCollection<GeoJSON.Polygon>;
const shape: GeoJSON.FeatureCollection<GeoJSON.Polygon> = { let handles: GeoJSON.FeatureCollection<GeoJSON.Point>;
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
const closedRing = [...ring, ring[0]];
shape = {
type: "FeatureCollection", type: "FeatureCollection",
features: [ features: [
{ {
@@ -52,7 +61,37 @@ export function createEditingEngine(options: {
], ],
}; };
const handles: GeoJSON.FeatureCollection<GeoJSON.Point> = { // Circle handles: 0 = center, 1 = radius control
const radiusHandlePoint = destinationPoint(editing.circleCenter, editing.circleRadius, 90);
handles = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: editing.circleCenter },
properties: { idx: 0, type: "center" },
},
{
type: "Feature",
geometry: { type: "Point", coordinates: radiusHandlePoint },
properties: { idx: 1, type: "radius" },
},
],
};
} else {
const closedRing = [...editing.ring, editing.ring[0]];
shape = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Polygon", coordinates: [closedRing] },
properties: {},
},
],
};
handles = {
type: "FeatureCollection", type: "FeatureCollection",
features: editing.ring.map((c, idx) => ({ features: editing.ring.map((c, idx) => ({
type: "Feature", type: "Feature",
@@ -60,6 +99,7 @@ export function createEditingEngine(options: {
properties: { idx }, properties: { idx },
})), })),
}; };
}
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape); (map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles); (map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
@@ -69,10 +109,23 @@ export function createEditingEngine(options: {
const finishEditing = () => { const finishEditing = () => {
const editing = editingRef.current; const editing = editingRef.current;
if (!editing) return; if (!editing) return;
const geometry: Geometry = {
let geometry: Geometry;
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
geometry = {
type: "Polygon",
coordinates: [[...ring, ring[0]]],
circle_center: editing.circleCenter,
circle_radius: editing.circleRadius,
};
} else {
geometry = {
type: "Polygon", type: "Polygon",
coordinates: [[...editing.ring, editing.ring[0]]], coordinates: [[...editing.ring, editing.ring[0]]],
}; };
}
onUpdate(editing.id, geometry); onUpdate(editing.id, geometry);
clearEditing(); clearEditing();
}; };
@@ -85,15 +138,21 @@ export function createEditingEngine(options: {
// Bắt đầu chỉnh sửa từ feature polygon được chọn. // Bắt đầu chỉnh sửa từ feature polygon được chọn.
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => { const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
if (feature.geometry.type !== "Polygon") return; if (feature.geometry.type !== "Polygon") return;
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][]; const geom = feature.geometry as Geometry;
const coords = (geom.coordinates?.[0] ?? []) as [number, number][];
if (coords.length < 4) return; if (coords.length < 4) return;
const isCircle = !!geom.circle_center;
// remove duplicated closing point // remove duplicated closing point
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]); const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
editingRef.current = { editingRef.current = {
id: feature.id ?? feature.properties?.id, id: feature.id ?? feature.properties?.id,
ring, ring,
original: feature.geometry as Geometry, original: geom,
isCircle,
circleCenter: geom.circle_center,
circleRadius: geom.circle_radius,
}; };
updateEditSources(); updateEditSources();
}; };
@@ -129,7 +188,20 @@ export function createEditingEngine(options: {
const editing = editingRef.current; const editing = editingRef.current;
if (!drag || !editing) return; if (!drag || !editing) return;
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
if (drag.idx === 0) {
// Move center
editing.circleCenter = [e.lngLat.lng, e.lngLat.lat];
} else if (drag.idx === 1) {
// Change radius
editing.circleRadius = distanceMeters(editing.circleCenter, [
e.lngLat.lng,
e.lngLat.lat,
]);
}
} else {
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat]; editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
}
updateEditSources(); updateEditSources();
}; };
@@ -166,7 +238,7 @@ export function createEditingEngine(options: {
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất. // Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => { const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current) return; if (!editingRef.current || editingRef.current.isCircle) return;
if (!isModifierPressed(e)) return; if (!isModifierPressed(e)) return;
e.preventDefault(); e.preventDefault();
const editing = editingRef.current; const editing = editingRef.current;
+18 -5
View File
@@ -7,7 +7,8 @@ export function initSelect(
getMode: ModeGetter, getMode: ModeGetter,
onDelete?: (id: string | number) => void, onDelete?: (id: string | number) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void, onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onSelectIds?: (ids: (string | number)[]) => void onSelectIds?: (ids: (string | number)[]) => void,
onReplayEdit?: (id: string | number) => void
) { ) {
const FEATURE_STATE_SOURCES = [ const FEATURE_STATE_SOURCES = [
@@ -16,7 +17,7 @@ export function initSelect(
"path-arrow-shapes", "path-arrow-shapes",
] as const; ] as const;
const selectedIds = new Set<number | string>(); const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit); const hasContextActions = Boolean(onDelete || onEdit || onReplayEdit);
let contextMenu: HTMLDivElement | null = null; let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null; let docClickHandler: ((ev: MouseEvent) => void) | null = null;
@@ -54,7 +55,7 @@ export function initSelect(
// Chọn feature theo click trái, hỗ trợ additive bằng Alt. // Chọn feature theo click trái, hỗ trợ additive bằng Alt.
function onClick(e: maplibregl.MapLayerMouseEvent) { function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return; if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers(); const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return; if (!selectableLayers.length) return;
@@ -74,11 +75,12 @@ export function initSelect(
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải. // Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
// Mở menu thao tác khi click phải lên feature. // Mở menu thao tác khi click phải lên feature.
function onRightClick(e: maplibregl.MapLayerMouseEvent) { function onRightClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return; if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers(); const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return; if (!selectableLayers.length) return;
e.preventDefault(); // block browser menu e.preventDefault(); // block browser menu
if (getMode() === "replay") return;
const features = map.queryRenderedFeatures(e.point, { const features = map.queryRenderedFeatures(e.point, {
layers: selectableLayers, layers: selectableLayers,
@@ -105,7 +107,7 @@ export function initSelect(
// Đổi cursor pointer khi hover lên đối tượng có thể chọn. // Đổi cursor pointer khi hover lên đối tượng có thể chọn.
function onMove(e: maplibregl.MapLayerMouseEvent) { function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return; if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers(); const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) { if (!selectableLayers.length) {
map.getCanvas().style.cursor = ""; map.getCanvas().style.cursor = "";
@@ -218,6 +220,17 @@ export function initSelect(
hasMenuItems = true; hasMenuItems = true;
} }
if (
selectedCount === 1 &&
onReplayEdit
) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId) {
menu.appendChild(createItem("Replay Edit", () => onReplayEdit(featureId)));
hasMenuItems = true;
}
}
if (onDelete) { if (onDelete) {
menu.appendChild( menu.appendChild(
createItem( createItem(
+79
View File
@@ -0,0 +1,79 @@
const EARTH_RADIUS_METERS = 6371008.8;
// Đổi đơn vị góc từ độ sang radian.
export function toRad(value: number): number {
return (value * Math.PI) / 180;
}
// Đổi đơn vị góc từ radian sang độ.
export function toDeg(value: number): number {
return (value * 180) / Math.PI;
}
// Kẹp giá trị trong đoạn [min, max].
export function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
// Chuẩn hóa kinh độ về miền [-180, 180].
export function normalizeLng(lng: number): number {
let normalized = ((lng + 540) % 360) - 180;
if (normalized === -180) normalized = 180;
return normalized;
}
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
export function distanceMeters(a: [number, number], b: [number, number]): number {
const lat1 = toRad(a[1]);
const lat2 = toRad(b[1]);
const dLat = lat2 - lat1;
const dLng = toRad(b[0] - a[0]);
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng;
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
return EARTH_RADIUS_METERS * c;
}
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
export function destinationPoint(
center: [number, number],
distance: number,
bearingDeg: number
): [number, number] {
const lat1 = toRad(center[1]);
const lng1 = toRad(center[0]);
const bearing = toRad(bearingDeg);
const angularDistance = distance / EARTH_RADIUS_METERS;
const sinLat1 = Math.sin(lat1);
const cosLat1 = Math.cos(lat1);
const sinAngular = Math.sin(angularDistance);
const cosAngular = Math.cos(angularDistance);
const sinLat2 = sinLat1 * cosAngular + cosLat1 * sinAngular * Math.cos(bearing);
const lat2 = Math.asin(clamp(sinLat2, -1, 1));
const y = Math.sin(bearing) * sinAngular * cosLat1;
const x = cosAngular - sinLat1 * Math.sin(lat2);
const lng2 = lng1 + Math.atan2(y, x);
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
}
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
export function buildCircleRing(
center: [number, number],
radiusMeters: number,
segments: number = 72
): [number, number][] {
const ring: [number, number][] = [];
for (let i = 0; i <= segments; i += 1) {
const bearingDeg = (i / segments) * 360;
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
}
return ring;
}
+17
View File
@@ -39,3 +39,20 @@ export function geoTypeCodeToTypeKey(code: number | null | undefined): string |
if (!Number.isFinite(code)) return null; if (!Number.isFinite(code)) return null;
return KEY_BY_CODE.get(Math.trunc(code)) ?? null; return KEY_BY_CODE.get(Math.trunc(code)) ?? null;
} }
export function normalizeGeoTypeKey(value: unknown): string | null {
if (typeof value === "number") {
return geoTypeCodeToTypeKey(value);
}
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (!normalized.length) return null;
if (/^-?\d+$/.test(normalized)) {
return geoTypeCodeToTypeKey(Number(normalized));
}
return normalized;
}
@@ -1,36 +1,36 @@
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
export const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""]; export const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
export { ensurePointGeotypeIcons } from "./pointStyle"; export { ensurePointGeotypeIcons } from "./shared/pointStyle";
import { getDefenseLineLayers } from "./defense_line"; import { getDefenseLineLayers } from "./geotypes/defense_line";
import { getAttackRouteLayers } from "./attack_route"; import { getAttackRouteLayers } from "./geotypes/attack_route";
import { getRetreatRouteLayers } from "./retreat_route"; import { getRetreatRouteLayers } from "./geotypes/retreat_route";
import { getInvasionRouteLayers } from "./invasion_route"; import { getInvasionRouteLayers } from "./geotypes/invasion_route";
import { getMigrationRouteLayers } from "./migration_route"; import { getMigrationRouteLayers } from "./geotypes/migration_route";
import { getRefugeeRouteLayers } from "./refugee_route"; import { getRefugeeRouteLayers } from "./geotypes/refugee_route";
import { getTradeRouteLayers } from "./trade_route"; import { getTradeRouteLayers } from "./geotypes/trade_route";
import { getShippingRouteLayers } from "./shipping_route"; import { getShippingRouteLayers } from "./geotypes/shipping_route";
import { getCountryLayers } from "./country"; import { getCountryLayers } from "./geotypes/country";
import { getStateLayers } from "./state"; import { getStateLayers } from "./geotypes/state";
import { getEmpireLayers } from "./empire"; import { getEmpireLayers } from "./geotypes/empire";
import { getKingdomLayers } from "./kingdom"; import { getKingdomLayers } from "./geotypes/kingdom";
import { getWarLayers } from "./war"; import { getWarLayers } from "./geotypes/war";
import { getBattleLayers } from "./battle"; import { getBattleLayers } from "./geotypes/battle";
import { getCivilizationLayers } from "./civilization"; import { getCivilizationLayers } from "./geotypes/civilization";
import { getRebellionZoneLayers } from "./rebellion_zone"; import { getRebellionZoneLayers } from "./geotypes/rebellion_zone";
import { getPersonDeathplaceLayers } from "./person_deathplace"; import { getPersonDeathplaceLayers } from "./geotypes/person_deathplace";
import { getPersonBirthplaceLayers } from "./person_birthplace"; import { getPersonBirthplaceLayers } from "./geotypes/person_birthplace";
import { getPersonActivityLayers } from "./person_activity"; import { getPersonActivityLayers } from "./geotypes/person_activity";
import { getTempleLayers } from "./temple"; import { getTempleLayers } from "./geotypes/temple";
import { getCapitalLayers } from "./capital"; import { getCapitalLayers } from "./geotypes/capital";
import { getCityLayers } from "./city"; import { getCityLayers } from "./geotypes/city";
import { getFortressLayers } from "./fortress"; import { getFortressLayers } from "./geotypes/fortress";
import { getCastleLayers } from "./castle"; import { getCastleLayers } from "./geotypes/castle";
import { getRuinLayers } from "./ruin"; import { getRuinLayers } from "./geotypes/ruin";
import { getPortLayers } from "./port"; import { getPortLayers } from "./geotypes/port";
import { getBridgeLayers } from "./bridge"; import { getBridgeLayers } from "./geotypes/bridge";
import { getLineLabelLayers } from "./lineLabels"; import { getLineLabelLayers } from "./shared/lineLabels";
import { getPolygonLabelLayers } from "./polygonLabels"; import { getPolygonLabelLayers } from "./shared/polygonLabels";
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
@@ -1,68 +1,13 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getAttackRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getAttackRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pointSourceId;
{ return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
id: "attack_route-line", typeId: "attack_route",
type: "line", color: "#ef4444",
source: sourceId, strokeColor: "#7f1d1d",
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "attack_route"]], width: { z1: 2.6, z4: 3.8, z6: 5 },
paint: { arrowOpacity: 0.9,
"line-color": [ });
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#ef4444"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 2.2, 4, 3.2, 6, 4.2],
"line-opacity": 0.9
}
},
{
id: "attack_route-hit",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "attack_route"]],
paint: {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 12, 4, 18, 6, 24],
"line-opacity": 0
}
},
{
id: "attack_route-path-arrow-fill",
type: "fill",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "attack_route"],
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#ef4444"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.92,
0.82
]
}
},
{
id: "attack_route-path-arrow-line",
type: "line",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "attack_route"],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#0f172a"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 0.45, 4, 0.8, 6, 1.2],
"line-opacity": 0.9
}
}
];
} }
+10 -36
View File
@@ -1,40 +1,14 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getBattleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getBattleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pathArrowSourceId;
{ void pointSourceId;
id: "battle-fill", return buildPolygonGeotypeLayers(sourceId, {
type: "fill", typeId: "battle",
source: sourceId, fillColor: "#f43f5e",
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "battle"]], strokeColor: "#9f1239",
paint: { fillOpacity: 0.3,
"fill-color": [ strokeWidth: { z1: 1.5, z4: 2.2, z6: 3 },
"case", });
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#f43f5e"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.6,
0.34
]
}
},
{
id: "battle-line",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "battle"]],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#9f1239"
],
"line-width": 2
}
}
];
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getBridgeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getBridgeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getCapitalLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getCapitalLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getCastleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getCastleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getCityLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getCityLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
@@ -1,40 +1,13 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getCivilizationLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getCivilizationLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pathArrowSourceId;
{ void pointSourceId;
id: "civilization-fill", return buildPolygonGeotypeLayers(sourceId, {
type: "fill", typeId: "civilization",
source: sourceId, fillColor: "#14b8a6",
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "civilization"]], strokeColor: "#134e4a",
paint: { fillOpacity: 0.34,
"fill-color": [ });
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#14b8a6"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.6,
0.38
]
}
},
{
id: "civilization-line",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "civilization"]],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#134e4a"
],
"line-width": 2
}
}
];
} }
+9 -36
View File
@@ -1,40 +1,13 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getCountryLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getCountryLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pathArrowSourceId;
{ void pointSourceId;
id: "country-fill", return buildPolygonGeotypeLayers(sourceId, {
type: "fill", typeId: "country",
source: sourceId, fillColor: "#2563eb",
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "country"]], strokeColor: "#1e40af",
paint: { fillOpacity: 0.34,
"fill-color": [ });
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#2563eb"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.6,
0.5
]
}
},
{
id: "country-line",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "country"]],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#1e3a8a"
],
"line-width": 2
}
}
];
} }
@@ -1,35 +1,13 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getDefenseLineLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getDefenseLineLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pointSourceId;
{ return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
id: "defense_line-line", typeId: "defense_line",
type: "line", color: "#38bdf8",
source: sourceId, strokeColor: "#075985",
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "defense_line"]], dasharray: [3, 2],
paint: { arrow: false,
"line-color": [ });
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#f97316"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 2.2, 4, 3.2, 6, 4.2],
"line-dasharray": [3, 2],
"line-opacity": 0.9
}
},
{
id: "defense_line-hit",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "defense_line"]],
paint: {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 12, 4, 18, 6, 24],
"line-opacity": 0
}
}
];
} }
+10 -36
View File
@@ -1,40 +1,14 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getEmpireLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getEmpireLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pathArrowSourceId;
{ void pointSourceId;
id: "empire-fill", return buildPolygonGeotypeLayers(sourceId, {
type: "fill", typeId: "empire",
source: sourceId, fillColor: "#f59e0b",
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "empire"]], strokeColor: "#92400e",
paint: { fillOpacity: 0.36,
"fill-color": [ strokeWidth: { z1: 1.8, z4: 2.6, z6: 3.4 },
"case", });
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#f59e0b"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.6,
0.5
]
}
},
{
id: "empire-line",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "empire"]],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#7c2d12"
],
"line-width": 2
}
}
];
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getFortressLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getFortressLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
@@ -1,68 +1,13 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getInvasionRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getInvasionRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pointSourceId;
{ return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
id: "invasion_route-line", typeId: "invasion_route",
type: "line", color: "#be123c",
source: sourceId, strokeColor: "#4c0519",
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "invasion_route"]], width: { z1: 2.8, z4: 4.1, z6: 5.4 },
paint: { arrowOpacity: 0.9,
"line-color": [ });
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#b91c1c"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 2.2, 4, 3.2, 6, 4.2],
"line-opacity": 0.9
}
},
{
id: "invasion_route-hit",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "invasion_route"]],
paint: {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 12, 4, 18, 6, 24],
"line-opacity": 0
}
},
{
id: "invasion_route-path-arrow-fill",
type: "fill",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "invasion_route"],
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#b91c1c"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.92,
0.82
]
}
},
{
id: "invasion_route-path-arrow-line",
type: "line",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "invasion_route"],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#0f172a"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 0.45, 4, 0.8, 6, 1.2],
"line-opacity": 0.9
}
}
];
} }
+9 -36
View File
@@ -1,40 +1,13 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getKingdomLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getKingdomLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pathArrowSourceId;
{ void pointSourceId;
id: "kingdom-fill", return buildPolygonGeotypeLayers(sourceId, {
type: "fill", typeId: "kingdom",
source: sourceId, fillColor: "#8b5cf6",
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "kingdom"]], strokeColor: "#6d28d9",
paint: { fillOpacity: 0.34,
"fill-color": [ });
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#d97706"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.6,
0.5
]
}
},
{
id: "kingdom-line",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "kingdom"]],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#9a3412"
],
"line-width": 2
}
}
];
} }
@@ -1,68 +1,13 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getMigrationRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getMigrationRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pointSourceId;
{ return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
id: "migration_route-line", typeId: "migration_route",
type: "line", color: "#10b981",
source: sourceId, strokeColor: "#065f46",
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "migration_route"]], dasharray: [4, 3],
paint: { arrowOpacity: 0.76,
"line-color": [ });
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#0ea5e9"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 2.2, 4, 3.2, 6, 4.2],
"line-opacity": 0.9
}
},
{
id: "migration_route-hit",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "migration_route"]],
paint: {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 12, 4, 18, 6, 24],
"line-opacity": 0
}
},
{
id: "migration_route-path-arrow-fill",
type: "fill",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "migration_route"],
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#0ea5e9"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.92,
0.82
]
}
},
{
id: "migration_route-path-arrow-line",
type: "line",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "migration_route"],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#0f172a"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 0.45, 4, 0.8, 6, 1.2],
"line-opacity": 0.9
}
}
];
} }
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getPersonActivityLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getPersonActivityLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getPersonBirthplaceLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getPersonBirthplaceLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getPersonDeathplaceLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getPersonDeathplaceLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getPortLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getPortLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
@@ -1,40 +1,14 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getRebellionZoneLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getRebellionZoneLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pathArrowSourceId;
{ void pointSourceId;
id: "rebellion_zone-fill", return buildPolygonGeotypeLayers(sourceId, {
type: "fill", typeId: "rebellion_zone",
source: sourceId, fillColor: "#a21caf",
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "rebellion_zone"]], strokeColor: "#701a75",
paint: { fillOpacity: 0.26,
"fill-color": [ dasharray: [3, 2],
"case", });
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#7c3aed"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.6,
0.32
]
}
},
{
id: "rebellion_zone-line",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "rebellion_zone"]],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#4c1d95"
],
"line-width": 2
}
}
];
} }
@@ -1,68 +1,14 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getRefugeeRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getRefugeeRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pointSourceId;
{ return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
id: "refugee_route-line", typeId: "refugee_route",
type: "line", color: "#f97316",
source: sourceId, strokeColor: "#9a3412",
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "refugee_route"]], dasharray: [1, 2],
paint: { opacity: 0.84,
"line-color": [ arrowOpacity: 0.72,
"case", });
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#06b6d4"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 2.2, 4, 3.2, 6, 4.2],
"line-opacity": 0.9
}
},
{
id: "refugee_route-hit",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "refugee_route"]],
paint: {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 12, 4, 18, 6, 24],
"line-opacity": 0
}
},
{
id: "refugee_route-path-arrow-fill",
type: "fill",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "refugee_route"],
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#06b6d4"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.92,
0.82
]
}
},
{
id: "refugee_route-path-arrow-line",
type: "line",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "refugee_route"],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#0f172a"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 0.45, 4, 0.8, 6, 1.2],
"line-opacity": 0.9
}
}
];
} }
@@ -1,68 +1,14 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getRetreatRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getRetreatRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pointSourceId;
{ return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
id: "retreat_route-line", typeId: "retreat_route",
type: "line", color: "#94a3b8",
source: sourceId, strokeColor: "#475569",
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "retreat_route"]], dasharray: [6, 3],
paint: { opacity: 0.82,
"line-color": [ arrowOpacity: 0.68,
"case", });
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#94a3b8"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 2.2, 4, 3.2, 6, 4.2],
"line-opacity": 0.9
}
},
{
id: "retreat_route-hit",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "retreat_route"]],
paint: {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 12, 4, 18, 6, 24],
"line-opacity": 0
}
},
{
id: "retreat_route-path-arrow-fill",
type: "fill",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "retreat_route"],
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#94a3b8"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.92,
0.82
]
}
},
{
id: "retreat_route-path-arrow-line",
type: "line",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "retreat_route"],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#0f172a"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 0.45, 4, 0.8, 6, 1.2],
"line-opacity": 0.9
}
}
];
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getRuinLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getRuinLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
@@ -1,68 +1,14 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getShippingRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getShippingRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pointSourceId;
{ return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
id: "shipping_route-line", typeId: "shipping_route",
type: "line", color: "#0ea5e9",
source: sourceId, strokeColor: "#075985",
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "shipping_route"]], width: { z1: 2.4, z4: 3.5, z6: 4.7 },
paint: { dasharray: [7, 4],
"line-color": [ arrowOpacity: 0.8,
"case", });
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#2563eb"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 2.2, 4, 3.2, 6, 4.2],
"line-opacity": 0.9
}
},
{
id: "shipping_route-hit",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "shipping_route"]],
paint: {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 12, 4, 18, 6, 24],
"line-opacity": 0
}
},
{
id: "shipping_route-path-arrow-fill",
type: "fill",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "shipping_route"],
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#2563eb"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.92,
0.82
]
}
},
{
id: "shipping_route-path-arrow-line",
type: "line",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "shipping_route"],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#0f172a"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 0.45, 4, 0.8, 6, 1.2],
"line-opacity": 0.9
}
}
];
} }
+10 -36
View File
@@ -1,40 +1,14 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getStateLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getStateLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pathArrowSourceId;
{ void pointSourceId;
id: "state-fill", return buildPolygonGeotypeLayers(sourceId, {
type: "fill", typeId: "state",
source: sourceId, fillColor: "#0891b2",
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "state"]], strokeColor: "#0e7490",
paint: { fillOpacity: 0.28,
"fill-color": [ strokeWidth: { z1: 1.1, z4: 1.7, z6: 2.4 },
"case", });
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#0ea5e9"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.6,
0.5
]
}
},
{
id: "state-line",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "state"]],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#0c4a6e"
],
"line-width": 2
}
}
];
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle"; import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getTempleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getTempleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId; void sourceId;
+9 -64
View File
@@ -1,68 +1,13 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getTradeRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getTradeRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pointSourceId;
{ return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
id: "trade_route-line", typeId: "trade_route",
type: "line", color: "#eab308",
source: sourceId, strokeColor: "#854d0e",
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "trade_route"]], dasharray: [5, 3],
paint: { arrowOpacity: 0.78,
"line-color": [ });
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#eab308"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 2.2, 4, 3.2, 6, 4.2],
"line-opacity": 0.9
}
},
{
id: "trade_route-hit",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", TYPE_MATCH_EXPR, "trade_route"]],
paint: {
"line-color": "#ffffff",
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 12, 4, 18, 6, 24],
"line-opacity": 0
}
},
{
id: "trade_route-path-arrow-fill",
type: "fill",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "trade_route"],
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#eab308"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.92,
0.82
]
}
},
{
id: "trade_route-path-arrow-line",
type: "line",
source: pathArrowSourceId!,
filter: ["==", TYPE_MATCH_EXPR, "trade_route"],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#0f172a"
],
"line-width": ["interpolate", ["linear"], ["zoom"], 1, 0.45, 4, 0.8, 6, 1.2],
"line-opacity": 0.9
}
}
];
} }
+10 -36
View File
@@ -1,40 +1,14 @@
import { LayerSpecification } from "maplibre-gl"; import { LayerSpecification } from "maplibre-gl";
import { TYPE_MATCH_EXPR } from "./index"; import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getWarLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] { export function getWarLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
return [ void pathArrowSourceId;
{ void pointSourceId;
id: "war-fill", return buildPolygonGeotypeLayers(sourceId, {
type: "fill", typeId: "war",
source: sourceId, fillColor: "#dc2626",
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "war"]], strokeColor: "#7f1d1d",
paint: { fillOpacity: 0.26,
"fill-color": [ dasharray: [5, 2],
"case", });
["boolean", ["feature-state", "selected"], false], "#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""], "#ef4444",
"#dc2626"
],
"fill-opacity": [
"case",
["boolean", ["feature-state", "selected"], false], 0.6,
0.3
]
}
},
{
id: "war-line",
type: "line",
source: sourceId,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", TYPE_MATCH_EXPR, "war"]],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#14532d",
"#7f1d1d"
],
"line-width": 2
}
}
];
} }
@@ -0,0 +1,44 @@
import maplibregl, { LayerSpecification } from "maplibre-gl";
const LINE_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
"any",
["==", ["geometry-type"], "LineString"],
["==", ["geometry-type"], "MultiLineString"],
];
export function getLineLabelLayers(sourceId: string): LayerSpecification[] {
return [
{
id: "line-labels-text",
type: "symbol",
source: sourceId,
filter: ["all", LINE_GEOMETRY_FILTER, ["!=", ["coalesce", ["get", "line_label"], ""], ""]],
layout: {
"symbol-placement": "line",
"symbol-spacing": 280,
"text-field": ["coalesce", ["get", "line_label"], ""],
"text-size": [
"interpolate",
["linear"],
["zoom"],
1, 11,
4, 13,
6, 15,
],
"text-keep-upright": true,
"text-max-angle": 35,
"text-max-width": 12,
"text-padding": 2,
"text-allow-overlap": false,
"text-ignore-placement": false,
"text-optional": true,
},
paint: {
"text-color": "#f8fafc",
"text-halo-color": "#0f172a",
"text-halo-width": 1.4,
"text-halo-blur": 0.25,
},
},
];
}
+494
View File
@@ -0,0 +1,494 @@
import maplibregl, { LayerSpecification } from "maplibre-gl";
export const POINT_GEOTYPE_IDS = [
"person_birthplace",
"person_deathplace",
"person_activity",
"temple",
"capital",
"city",
"fortress",
"castle",
"ruin",
"port",
"bridge",
] as const;
export type PointGeotypeId = (typeof POINT_GEOTYPE_IDS)[number];
type PointIconVariant = "default" | "draft";
type PointLayerOptions = {
iconScale?: number;
haloRadius?: number;
};
type PointStyleConfig = {
fill: string;
rim: string;
iconScale: number;
haloRadius: number;
drawGlyph: (ctx: CanvasRenderingContext2D) => void;
};
const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
const DRAFT_ENTITY_EXPR: maplibregl.ExpressionSpecification = ["==", ["coalesce", ["get", "entity_id"], ""], ""];
const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
const ICON_CANVAS_SIZE = 64;
const DRAFT_FILL = "#ef4444";
const DRAFT_RIM = "#7f1d1d";
const POINT_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
"any",
["==", ["geometry-type"], "Point"],
["==", ["geometry-type"], "MultiPoint"],
];
const POINT_STYLE_CONFIG: Record<PointGeotypeId, PointStyleConfig> = {
person_birthplace: {
fill: "#22c55e",
rim: "#166534",
iconScale: 1,
haloRadius: 15,
drawGlyph: drawHouseGlyph,
},
person_deathplace: {
fill: "#b91c1c",
rim: "#450a0a",
iconScale: 1,
haloRadius: 15,
drawGlyph: drawMemorialGlyph,
},
person_activity: {
fill: "#f97316",
rim: "#9a3412",
iconScale: 0.98,
haloRadius: 14,
drawGlyph: drawFlagGlyph,
},
temple: {
fill: "#d97706",
rim: "#78350f",
iconScale: 1.02,
haloRadius: 15,
drawGlyph: drawTempleGlyph,
},
capital: {
fill: "#eab308",
rim: "#854d0e",
iconScale: 1.08,
haloRadius: 17,
drawGlyph: drawCrownGlyph,
},
city: {
fill: "#2563eb",
rim: "#1e3a8a",
iconScale: 1.02,
haloRadius: 15,
drawGlyph: drawCityGlyph,
},
fortress: {
fill: "#64748b",
rim: "#334155",
iconScale: 1.04,
haloRadius: 16,
drawGlyph: drawShieldGlyph,
},
castle: {
fill: "#7c3aed",
rim: "#4c1d95",
iconScale: 1.04,
haloRadius: 16,
drawGlyph: drawCastleGlyph,
},
ruin: {
fill: "#78716c",
rim: "#44403c",
iconScale: 0.98,
haloRadius: 14,
drawGlyph: drawRuinGlyph,
},
port: {
fill: "#0284c7",
rim: "#075985",
iconScale: 1.02,
haloRadius: 15,
drawGlyph: drawAnchorGlyph,
},
bridge: {
fill: "#b45309",
rim: "#7c2d12",
iconScale: 1,
haloRadius: 14,
drawGlyph: drawBridgeGlyph,
},
};
export function buildPointGeotypeLayers(
typeId: PointGeotypeId,
pointSourceId: string,
options: PointLayerOptions = {}
): LayerSpecification[] {
const config = POINT_STYLE_CONFIG[typeId];
const haloRadius = (options.haloRadius ?? config.haloRadius) * 2;
const iconScale = options.iconScale ?? config.iconScale;
return [
{
id: `${typeId}-selected-halo`,
type: "circle",
source: pointSourceId,
filter: pointFilter(typeId),
paint: {
"circle-color": "#22c55e",
"circle-radius": ["case", SELECTED_EXPR, haloRadius, 0],
"circle-opacity": ["case", SELECTED_EXPR, 0.24, 0],
"circle-blur": ["case", SELECTED_EXPR, 0.8, 0],
"circle-stroke-color": "#14532d",
"circle-stroke-width": ["case", SELECTED_EXPR, 1.6, 0],
"circle-stroke-opacity": ["case", SELECTED_EXPR, 0.48, 0],
},
},
{
id: `${typeId}-circle`,
type: "symbol",
source: pointSourceId,
filter: pointFilter(typeId),
layout: {
"icon-image": pointIconExpression(typeId),
"icon-size": [
"interpolate",
["linear"],
["zoom"],
1, 0.96 * iconScale,
4, 1.24 * iconScale,
6, 1.52 * iconScale,
],
"icon-anchor": "center",
"icon-allow-overlap": true,
"icon-ignore-placement": true,
"symbol-placement": "point",
"text-field": ["coalesce", ["get", "point_label"], ""],
"text-size": [
"interpolate",
["linear"],
["zoom"],
1, 11,
4, 13,
6, 15,
],
"text-anchor": "bottom",
"text-offset": [0, -1.25],
"text-allow-overlap": true,
"text-ignore-placement": true,
"text-optional": true,
"text-max-width": 12,
},
paint: {
"icon-opacity": 0.98,
"text-color": "#f8fafc",
"text-halo-color": "#0f172a",
"text-halo-width": 1.4,
"text-halo-blur": 0.3,
},
},
];
}
export function ensurePointGeotypeIcons(map: maplibregl.Map): boolean {
if (typeof document === "undefined") return false;
for (const typeId of POINT_GEOTYPE_IDS) {
for (const variant of ["default", "draft"] as const) {
const iconId = getPointIconId(typeId, variant);
if (map.hasImage(iconId)) continue;
const imageData = createPointIconImageData(typeId, variant);
if (!imageData) return false;
map.addImage(iconId, imageData, { pixelRatio: 2 });
}
}
return true;
}
function pointFilter(typeId: PointGeotypeId): maplibregl.ExpressionSpecification {
return ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
}
function pointIconExpression(typeId: PointGeotypeId): maplibregl.ExpressionSpecification {
return ["case", DRAFT_ENTITY_EXPR, getPointIconId(typeId, "draft"), getPointIconId(typeId, "default")];
}
function getPointIconId(typeId: PointGeotypeId, variant: PointIconVariant): string {
return `point-${typeId}-${variant}`;
}
function createPointIconImageData(typeId: PointGeotypeId, variant: PointIconVariant): ImageData | null {
const config = POINT_STYLE_CONFIG[typeId];
const palette = variant === "draft"
? { fill: DRAFT_FILL, rim: DRAFT_RIM }
: { fill: config.fill, rim: config.rim };
const canvas = document.createElement("canvas");
canvas.width = ICON_CANVAS_SIZE;
canvas.height = ICON_CANVAS_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.clearRect(0, 0, ICON_CANVAS_SIZE, ICON_CANVAS_SIZE);
drawGlyphWithOutline(ctx, palette.fill, palette.rim, () => config.drawGlyph(ctx));
return ctx.getImageData(0, 0, ICON_CANVAS_SIZE, ICON_CANVAS_SIZE);
}
function drawGlyphWithOutline(
ctx: CanvasRenderingContext2D,
fill: string,
rim: string,
draw: () => void
) {
ctx.save();
ctx.shadowColor = "rgba(15, 23, 42, 0.35)";
ctx.shadowBlur = 6;
ctx.shadowOffsetY = 2;
ctx.strokeStyle = rim;
ctx.fillStyle = rim;
ctx.lineCap = "round";
ctx.lineJoin = "round";
draw();
ctx.restore();
ctx.save();
ctx.strokeStyle = fill;
ctx.fillStyle = fill;
ctx.lineCap = "round";
ctx.lineJoin = "round";
draw();
ctx.restore();
}
function drawHouseGlyph(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.moveTo(22, 34);
ctx.lineTo(32, 24);
ctx.lineTo(42, 34);
ctx.stroke();
ctx.beginPath();
ctx.rect(25.5, 34, 13, 9);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(32, 43);
ctx.lineTo(32, 36.5);
ctx.stroke();
}
function drawMemorialGlyph(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 3.6;
ctx.beginPath();
ctx.moveTo(32, 22);
ctx.lineTo(32, 43);
ctx.moveTo(25, 28.5);
ctx.lineTo(39, 28.5);
ctx.stroke();
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.moveTo(24, 45);
ctx.lineTo(40, 45);
ctx.stroke();
}
function drawFlagGlyph(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 3.2;
ctx.beginPath();
ctx.moveTo(26, 22);
ctx.lineTo(26, 43);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(28, 23);
ctx.lineTo(40, 27);
ctx.lineTo(28, 31);
ctx.closePath();
ctx.fill();
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.moveTo(22.5, 44.5);
ctx.lineTo(31, 44.5);
ctx.stroke();
}
function drawTempleGlyph(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(22, 30);
ctx.lineTo(32, 22);
ctx.lineTo(42, 30);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(21, 31);
ctx.lineTo(43, 31);
ctx.moveTo(23, 42);
ctx.lineTo(41, 42);
ctx.stroke();
ctx.lineWidth = 2.8;
for (const x of [26, 32, 38]) {
ctx.beginPath();
ctx.moveTo(x, 31);
ctx.lineTo(x, 42);
ctx.stroke();
}
}
function drawCrownGlyph(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(22, 41);
ctx.lineTo(24.5, 28);
ctx.lineTo(30, 34);
ctx.lineTo(32, 23);
ctx.lineTo(34, 34);
ctx.lineTo(39.5, 28);
ctx.lineTo(42, 41);
ctx.closePath();
ctx.stroke();
ctx.lineWidth = 2.6;
ctx.beginPath();
ctx.moveTo(23.5, 41.5);
ctx.lineTo(40.5, 41.5);
ctx.stroke();
}
function drawCityGlyph(ctx: CanvasRenderingContext2D) {
ctx.fillRect(23, 33, 7, 10);
ctx.fillRect(30, 27, 6, 16);
ctx.fillRect(36, 30, 6, 13);
ctx.clearRect(25, 36, 1.5, 1.5);
ctx.clearRect(25, 39, 1.5, 1.5);
ctx.clearRect(32, 31, 1.5, 1.5);
ctx.clearRect(32, 35, 1.5, 1.5);
ctx.clearRect(38, 33, 1.5, 1.5);
ctx.clearRect(38, 37, 1.5, 1.5);
}
function drawShieldGlyph(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 3.2;
ctx.beginPath();
ctx.moveTo(32, 22.5);
ctx.lineTo(41, 26.5);
ctx.lineTo(39, 37.5);
ctx.lineTo(32, 43);
ctx.lineTo(25, 37.5);
ctx.lineTo(23, 26.5);
ctx.closePath();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(32, 25);
ctx.lineTo(32, 39);
ctx.stroke();
}
function drawCastleGlyph(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 3;
ctx.beginPath();
ctx.rect(24, 31, 16, 11);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(24, 31);
ctx.lineTo(24, 26);
ctx.lineTo(28, 26);
ctx.lineTo(28, 29);
ctx.lineTo(32, 29);
ctx.lineTo(32, 24);
ctx.lineTo(36, 24);
ctx.lineTo(36, 29);
ctx.lineTo(40, 29);
ctx.lineTo(40, 26);
ctx.lineTo(44, 26);
ctx.lineTo(44, 31);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(32, 42);
ctx.lineTo(32, 34);
ctx.stroke();
}
function drawRuinGlyph(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 3;
ctx.beginPath();
ctx.rect(26, 24, 12, 18);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(24, 24);
ctx.lineTo(40, 24);
ctx.moveTo(24, 42);
ctx.lineTo(40, 42);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(34, 24);
ctx.lineTo(31, 29);
ctx.lineTo(35, 33);
ctx.lineTo(30, 39);
ctx.stroke();
}
function drawAnchorGlyph(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(32, 22.5, 3.5, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(32, 26.5);
ctx.lineTo(32, 41);
ctx.moveTo(24, 31.5);
ctx.lineTo(40, 31.5);
ctx.stroke();
ctx.beginPath();
ctx.arc(32, 35.5, 9, 0.2 * Math.PI, 0.8 * Math.PI);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(24.5, 38);
ctx.lineTo(21.5, 34);
ctx.moveTo(39.5, 38);
ctx.lineTo(42.5, 34);
ctx.stroke();
}
function drawBridgeGlyph(ctx: CanvasRenderingContext2D) {
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(21, 31);
ctx.lineTo(43, 31);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(22, 40);
ctx.quadraticCurveTo(32, 26, 42, 40);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(26, 37);
ctx.lineTo(26, 31);
ctx.moveTo(32, 33.5);
ctx.lineTo(32, 31);
ctx.moveTo(38, 37);
ctx.lineTo(38, 31);
ctx.stroke();
}
@@ -0,0 +1,33 @@
import { LayerSpecification } from "maplibre-gl";
export function getPolygonLabelLayers(sourceId: string): LayerSpecification[] {
return [
{
id: "polygon-labels-text",
type: "symbol",
source: sourceId,
layout: {
"text-field": ["coalesce", ["get", "polygon_label"], ""],
"text-size": [
"interpolate",
["linear"],
["zoom"],
1, 12,
4, 15,
6, 18,
],
"text-anchor": "center",
"text-allow-overlap": true,
"text-ignore-placement": true,
"text-max-width": 14,
"symbol-placement": "point",
},
paint: {
"text-color": "#f8fafc",
"text-halo-color": "#0f172a",
"text-halo-width": 1.6,
"text-halo-blur": 0.35,
},
},
];
}
@@ -0,0 +1,203 @@
import maplibregl, { LayerSpecification } from "maplibre-gl";
const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
const DRAFT_ENTITY_EXPR: maplibregl.ExpressionSpecification = ["==", ["coalesce", ["get", "entity_id"], ""], ""];
const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
const SELECTED_COLOR = "#22c55e";
const SELECTED_STROKE = "#14532d";
const DRAFT_COLOR = "#ef4444";
const DRAFT_STROKE = "#7f1d1d";
type ZoomStops = {
z1: number;
z4: number;
z6: number;
};
type LineGeotypeStyle = {
typeId: string;
color: string;
strokeColor?: string;
opacity?: number;
width?: ZoomStops;
dasharray?: number[];
arrow?: boolean;
arrowOpacity?: number;
arrowOutlineColor?: string;
arrowOutlineWidth?: ZoomStops;
};
type PolygonGeotypeStyle = {
typeId: string;
fillColor: string;
strokeColor: string;
fillOpacity: number;
strokeWidth?: ZoomStops;
dasharray?: number[];
};
const DEFAULT_LINE_WIDTH: ZoomStops = { z1: 2.2, z4: 3.2, z6: 4.2 };
const DEFAULT_ARROW_OUTLINE_WIDTH: ZoomStops = { z1: 0.45, z4: 0.8, z6: 1.2 };
const DEFAULT_POLYGON_STROKE_WIDTH: ZoomStops = { z1: 1.4, z4: 2, z6: 2.8 };
const LINE_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
"any",
["==", ["geometry-type"], "LineString"],
["==", ["geometry-type"], "MultiLineString"],
];
const POLYGON_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
"any",
["==", ["geometry-type"], "Polygon"],
["==", ["geometry-type"], "MultiPolygon"],
];
export function buildLineGeotypeLayers(
sourceId: string,
pathArrowSourceId: string | undefined,
style: LineGeotypeStyle
): LayerSpecification[] {
const lineLayer: LayerSpecification = {
id: `${style.typeId}-line`,
type: "line",
source: sourceId,
filter: lineFilter(style.typeId),
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": statusColor(style.color),
"line-width": widthStops(style.width ?? DEFAULT_LINE_WIDTH),
"line-opacity": style.opacity ?? 0.9,
...(style.dasharray ? { "line-dasharray": style.dasharray } : {}),
},
};
const hitLayer: LayerSpecification = {
id: `${style.typeId}-hit`,
type: "line",
source: sourceId,
filter: lineFilter(style.typeId),
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": "#ffffff",
"line-width": widthStops({ z1: 12, z4: 18, z6: 24 }),
"line-opacity": 0,
},
};
if (style.arrow === false || !pathArrowSourceId) {
return [lineLayer, hitLayer];
}
return [
lineLayer,
hitLayer,
{
id: `${style.typeId}-path-arrow-fill`,
type: "fill",
source: pathArrowSourceId,
filter: ["==", TYPE_MATCH_EXPR, style.typeId],
paint: {
"fill-color": statusColor(style.color),
"fill-opacity": [
"case",
SELECTED_EXPR,
0.92,
style.arrowOpacity ?? 0.82,
],
},
},
{
id: `${style.typeId}-path-arrow-line`,
type: "line",
source: pathArrowSourceId,
filter: ["==", TYPE_MATCH_EXPR, style.typeId],
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": statusStroke(style.arrowOutlineColor ?? style.strokeColor ?? "#0f172a"),
"line-width": widthStops(style.arrowOutlineWidth ?? DEFAULT_ARROW_OUTLINE_WIDTH),
"line-opacity": 0.9,
},
},
];
}
export function buildPolygonGeotypeLayers(
sourceId: string,
style: PolygonGeotypeStyle
): LayerSpecification[] {
return [
{
id: `${style.typeId}-fill`,
type: "fill",
source: sourceId,
filter: polygonFilter(style.typeId),
paint: {
"fill-color": statusColor(style.fillColor),
"fill-opacity": [
"case",
SELECTED_EXPR,
0.58,
style.fillOpacity,
],
},
},
{
id: `${style.typeId}-line`,
type: "line",
source: sourceId,
filter: polygonFilter(style.typeId),
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": statusStroke(style.strokeColor),
"line-width": widthStops(style.strokeWidth ?? DEFAULT_POLYGON_STROKE_WIDTH),
"line-opacity": 0.95,
...(style.dasharray ? { "line-dasharray": style.dasharray } : {}),
},
},
];
}
function statusColor(normalColor: string): maplibregl.ExpressionSpecification {
return [
"case",
SELECTED_EXPR,
SELECTED_COLOR,
DRAFT_ENTITY_EXPR,
DRAFT_COLOR,
normalColor,
];
}
function statusStroke(normalColor: string): maplibregl.ExpressionSpecification {
return [
"case",
SELECTED_EXPR,
SELECTED_STROKE,
DRAFT_ENTITY_EXPR,
DRAFT_STROKE,
normalColor,
];
}
function lineFilter(typeId: string): maplibregl.ExpressionSpecification {
return ["all", LINE_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
}
function polygonFilter(typeId: string): maplibregl.ExpressionSpecification {
return ["all", POLYGON_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
}
function widthStops(stops: ZoomStops): maplibregl.ExpressionSpecification {
return ["interpolate", ["linear"], ["zoom"], 1, stops.z1, 4, stops.z4, 6, stops.z6];
}
+59 -6
View File
@@ -15,17 +15,70 @@ export type CommitSnapshot = {
geometry_entity: GeometryEntitySnapshot[]; geometry_entity: GeometryEntitySnapshot[];
wikis: WikiSnapshot[]; wikis: WikiSnapshot[];
entity_wiki: EntityWikiLinkSnapshot[]; entity_wiki: EntityWikiLinkSnapshot[];
replays?: BattleReplay[];
};
// ---- Replay / Scripting System ----
export type UIFunctionName =
| "hide_timeline"
| "hide_layer_panel"
| "hide_wiki_panel"
| "hide_zoom_panel"
| "hide_all_UI"
| "open_wiki";
export type MapFunctionName =
| "zoom_to_lnglat"
| "zoom_scale"
| "zoom_geometries"
| "change_geometry_color"
| "change_geometries_color"
| "change_geometry_texture"
| "change_geometries_texture"
| "hide_geometries";
export type NarrativeFunctionName = "set_title" | "set_descriptions";
export type ReplayAction<T> = {
function_name: T;
params: any[];
};
export type ReplayStep = {
duration: number; // Trọng số thời gian của step trong 1 stage
use_UI_function: ReplayAction<UIFunctionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
export type ReplayStage = {
id: number; // số đếm thứ tự từ 0
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
export type BattleReplay = {
geometry_id: string; // geometry mà khi nhấn vào là có thể replay
detail: ReplayStage[];
}; };
// ---- GeoJSON / FeatureCollection ---- // ---- GeoJSON / FeatureCollection ----
export type Geometry = export type Geometry =
| { type: "Point"; coordinates: [number, number] } | ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
| { type: "MultiPoint"; coordinates: [number, number][] } | ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
| { type: "LineString"; coordinates: [number, number][] } | ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
| { type: "MultiLineString"; coordinates: [number, number][][] } | ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| { type: "Polygon"; coordinates: [number, number][][] } | ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| { type: "MultiPolygon"; coordinates: [number, number][][][] }; | ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
export type CircleGeometryMetadata = {
circle_center?: [number, number];
circle_radius?: number;
};
export type FeatureId = string | number; export type FeatureId = string | number;
+11 -6
View File
@@ -1,12 +1,17 @@
import type { GeometryPreset } from "@/uhm/lib/map/geo/geometryTypeOptions"; import type { GeometryPreset } from "@/uhm/lib/map/geo/geometryTypeOptions";
export type Geometry = export type Geometry =
| { type: "Point"; coordinates: [number, number] } | ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
| { type: "MultiPoint"; coordinates: [number, number][] } | ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
| { type: "LineString"; coordinates: [number, number][] } | ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
| { type: "MultiLineString"; coordinates: [number, number][][] } | ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| { type: "Polygon"; coordinates: [number, number][][] } | ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
| { type: "MultiPolygon"; coordinates: [number, number][][][] }; | ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
export type CircleGeometryMetadata = {
circle_center?: [number, number];
circle_radius?: number;
};
export type FeatureId = string | number; export type FeatureId = string | number;