Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f904f91a9c | |||
| 3c71249926 | |||
| 8fc9456a6a | |||
| c92aaafc33 | |||
| f1d6f22f80 | |||
| 14a06af343 | |||
| 41e43d4974 | |||
| 33a866b659 | |||
| 08120ef987 | |||
| e725b52590 |
Generated
+164
-61
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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ó tiêu đề (H1/H2/...).</div>
|
<h2 className="text-xl mb-4 font-normal">So sánh các phiên 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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 có 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 có 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ụ";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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: {},
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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[] };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 độ.
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
+30
-30
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,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,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,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user