Compare commits
34 Commits
cf880f573f
...
f904f91a9c
| Author | SHA1 | Date | |
|---|---|---|---|
| f904f91a9c | |||
| 3c71249926 | |||
| 8fc9456a6a | |||
| c92aaafc33 | |||
| f1d6f22f80 | |||
| 14a06af343 | |||
| 41e43d4974 | |||
| 33a866b659 | |||
| 08120ef987 | |||
| e725b52590 | |||
| cb3e720644 | |||
| 94c58e1d42 | |||
| 72d7073c40 | |||
| 51f432f0fe | |||
| 7b1f7538ab | |||
| d40c3831cb | |||
| 8f6d848d55 | |||
| 8f911abe35 | |||
| 16fce9da7a | |||
| 6076f098fa | |||
| eecedec560 | |||
| 1b321da6aa | |||
| 1baba25303 | |||
| ac8b0404dd | |||
| fe7696b72d | |||
| f2f5295218 | |||
| 91d9d20447 | |||
| 9be308b65c | |||
| c371d70993 | |||
| b14f11574b | |||
| 31297c8b59 | |||
| 78824ed07a | |||
| 655454d83a | |||
| 6757eb2086 |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Generated
+182
-61
@@ -28,6 +28,8 @@
|
|||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
"maplibre-gl": "^5.20.2",
|
"maplibre-gl": "^5.20.2",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
"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",
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@types/node": "^20.19.25",
|
"@types/node": "^20.19.25",
|
||||||
|
"@types/polylabel": "^1.1.3",
|
||||||
"@types/react": "^19.2.1",
|
"@types/react": "^19.2.1",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
"@types/react-transition-group": "^4.4.12",
|
"@types/react-transition-group": "^4.4.12",
|
||||||
@@ -100,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",
|
||||||
@@ -1953,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"
|
||||||
}
|
}
|
||||||
@@ -3025,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"
|
||||||
@@ -3049,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"
|
||||||
},
|
},
|
||||||
@@ -3226,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",
|
||||||
@@ -3669,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"
|
||||||
@@ -3986,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",
|
||||||
@@ -4134,18 +4130,23 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/polylabel": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -4156,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"
|
||||||
}
|
}
|
||||||
@@ -4231,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",
|
||||||
@@ -4763,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"
|
||||||
},
|
},
|
||||||
@@ -4819,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",
|
||||||
@@ -5231,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",
|
||||||
@@ -5250,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",
|
||||||
@@ -5282,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",
|
||||||
@@ -5367,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",
|
||||||
@@ -5651,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",
|
||||||
@@ -5672,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",
|
||||||
@@ -5690,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",
|
||||||
@@ -6094,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",
|
||||||
@@ -6280,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",
|
||||||
@@ -6521,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",
|
||||||
@@ -6773,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"
|
||||||
@@ -6961,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"
|
||||||
@@ -7113,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",
|
||||||
@@ -7265,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",
|
||||||
@@ -7394,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",
|
||||||
@@ -7580,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",
|
||||||
@@ -8444,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"
|
||||||
@@ -8619,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",
|
||||||
@@ -8724,6 +8782,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/polylabel": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/polylabel/-/polylabel-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-B6Yu+Bdl/8SGtjVhyUfZzD3DwciCS9SPVtHiNdt8idHHatvTHp5Ss8XGDRmQFtfF1ZQnfK+Cj5dXdpkUXBbXgA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -8753,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",
|
||||||
@@ -8918,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"
|
||||||
}
|
}
|
||||||
@@ -8948,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",
|
||||||
@@ -8997,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",
|
||||||
@@ -9066,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": {
|
||||||
@@ -9085,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",
|
||||||
@@ -9095,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"
|
||||||
}
|
}
|
||||||
@@ -9162,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"
|
||||||
},
|
},
|
||||||
@@ -9208,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"
|
||||||
@@ -9236,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",
|
||||||
@@ -9295,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",
|
||||||
@@ -9538,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",
|
||||||
@@ -9556,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",
|
||||||
@@ -10061,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",
|
||||||
@@ -10118,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"
|
||||||
},
|
},
|
||||||
@@ -10296,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"
|
||||||
@@ -10675,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"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -29,6 +29,8 @@
|
|||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
"maplibre-gl": "^5.20.2",
|
"maplibre-gl": "^5.20.2",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
"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",
|
||||||
@@ -37,17 +39,18 @@
|
|||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-quill-new": "^3.8.3",
|
"react-quill-new": "^3.8.3",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"uuid": "^13.0.0",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"sweetalert2": "^11.26.24",
|
"sweetalert2": "^11.26.24",
|
||||||
"swiper": "^11.2.10",
|
"swiper": "^11.2.10",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"yet-another-react-lightbox": "^3.30.1"
|
"yet-another-react-lightbox": "^3.30.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@types/node": "^20.19.25",
|
"@types/node": "^20.19.25",
|
||||||
|
"@types/polylabel": "^1.1.3",
|
||||||
"@types/react": "^19.2.1",
|
"@types/react": "^19.2.1",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
"@types/react-transition-group": "^4.4.12",
|
"@types/react-transition-group": "^4.4.12",
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ 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 = {
|
||||||
editor: EditorDraftApi;
|
editor: EditorDraftApi;
|
||||||
selectedFeature: Feature | null;
|
selectedFeatures: Feature[];
|
||||||
geometryMetaForm: GeometryMetaFormState;
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
|
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
|
||||||
selectedGeometryEntityIds: string[];
|
selectedGeometryEntityIds: string[];
|
||||||
@@ -29,7 +33,7 @@ type Options = {
|
|||||||
export function useFeatureCommands(options: Options) {
|
export function useFeatureCommands(options: Options) {
|
||||||
const {
|
const {
|
||||||
editor,
|
editor,
|
||||||
selectedFeature,
|
selectedFeatures,
|
||||||
geometryMetaForm,
|
geometryMetaForm,
|
||||||
setGeometryMetaForm,
|
setGeometryMetaForm,
|
||||||
selectedGeometryEntityIds,
|
selectedGeometryEntityIds,
|
||||||
@@ -40,8 +44,8 @@ export function useFeatureCommands(options: Options) {
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
|
const applyGeometryMetadata = useCallback(async (): Promise<{ ok: boolean; error?: string }> => {
|
||||||
if (!selectedFeature) {
|
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||||
const msg = "Hãy chọn một geometry trước.";
|
const msg = "Hãy chọn ít nhất một geometry trước.";
|
||||||
setEntityFormStatus(msg);
|
setEntityFormStatus(msg);
|
||||||
return { ok: false, error: msg };
|
return { ok: false, error: msg };
|
||||||
}
|
}
|
||||||
@@ -64,7 +68,13 @@ export function useFeatureCommands(options: Options) {
|
|||||||
setIsEntitySubmitting(true);
|
setIsEntitySubmitting(true);
|
||||||
setEntityFormStatus(null);
|
setEntityFormStatus(null);
|
||||||
try {
|
try {
|
||||||
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
|
editor.patchFeaturePropertiesBatch(
|
||||||
|
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 };
|
||||||
@@ -74,15 +84,15 @@ export function useFeatureCommands(options: Options) {
|
|||||||
}, [
|
}, [
|
||||||
editor,
|
editor,
|
||||||
geometryMetaForm,
|
geometryMetaForm,
|
||||||
selectedFeature,
|
selectedFeatures,
|
||||||
setEntityFormStatus,
|
setEntityFormStatus,
|
||||||
setGeometryMetaForm,
|
setGeometryMetaForm,
|
||||||
setIsEntitySubmitting,
|
setIsEntitySubmitting,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const applyEntitiesToSelectedGeometry = useCallback(async () => {
|
const applyEntitiesToSelectedGeometry = useCallback(async () => {
|
||||||
if (!selectedFeature) {
|
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
setEntityFormStatus("Hãy chọn ít nhất một geometry trước.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,9 +100,12 @@ export function useFeatureCommands(options: Options) {
|
|||||||
setIsEntitySubmitting(true);
|
setIsEntitySubmitting(true);
|
||||||
setEntityFormStatus(null);
|
setEntityFormStatus(null);
|
||||||
try {
|
try {
|
||||||
editor.patchFeatureProperties(
|
editor.patchFeaturePropertiesBatch(
|
||||||
selectedFeature.properties.id,
|
selectedFeatures.map((feature) => ({
|
||||||
buildFeatureEntityPatch(selectedFeature, entityIds, entities)
|
id: feature.properties.id,
|
||||||
|
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.");
|
||||||
@@ -108,7 +121,7 @@ export function useFeatureCommands(options: Options) {
|
|||||||
}, [
|
}, [
|
||||||
editor,
|
editor,
|
||||||
entities,
|
entities,
|
||||||
selectedFeature,
|
selectedFeatures,
|
||||||
selectedGeometryEntityIds,
|
selectedGeometryEntityIds,
|
||||||
setEntityFormStatus,
|
setEntityFormStatus,
|
||||||
setIsEntitySubmitting,
|
setIsEntitySubmitting,
|
||||||
|
|||||||
+307
-156
@@ -4,40 +4,41 @@ import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction,
|
|||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import Map from "@/uhm/components/Map";
|
import Map from "@/uhm/components/Map";
|
||||||
import Editor from "@/uhm/components/Editor";
|
import Editor from "@/uhm/components/Editor";
|
||||||
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
|
import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
|
||||||
import TimelineBar from "@/uhm/components/TimelineBar";
|
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||||
import SelectedGeometryPanel from "@/uhm/components/SelectedGeometryPanel";
|
import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel";
|
||||||
import WikiSidebarPanel from "@/uhm/components/WikiSidebarPanel";
|
import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel";
|
||||||
import ProjectEntityRefsPanel from "@/uhm/components/ProjectEntityRefsPanel";
|
import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel";
|
||||||
import EntityWikiBindingsPanel from "@/uhm/components/EntityWikiBindingsPanel";
|
import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel";
|
||||||
import GeometryBindingPanel from "@/uhm/components/GeometryBindingPanel";
|
import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel";
|
||||||
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
|
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
|
||||||
import { ApiError } from "@/uhm/api/http";
|
import { ApiError } from "@/uhm/api/http";
|
||||||
import { fetchCurrentUser } from "@/uhm/api/auth";
|
import { fetchCurrentUser } from "@/uhm/api/auth";
|
||||||
import { SectionCommit } from "@/uhm/api/sections";
|
import { ProjectCommit } from "@/uhm/api/projects";
|
||||||
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||||
import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
|
import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import {
|
import {
|
||||||
Feature,
|
Feature,
|
||||||
|
FeatureCollection,
|
||||||
Geometry,
|
Geometry,
|
||||||
useEditorState,
|
useEditorState,
|
||||||
} from "@/uhm/lib/useEditorState";
|
} from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import { geoTypeCodeToTypeKey } from "@/uhm/lib/geoTypeMap";
|
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||||
import {
|
import {
|
||||||
BackgroundLayerId,
|
BackgroundLayerId,
|
||||||
BackgroundLayerVisibility,
|
BackgroundLayerVisibility,
|
||||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
} from "@/uhm/lib/backgroundLayers";
|
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
import {
|
import {
|
||||||
ENTITY_TYPE_OPTIONS,
|
GEOMETRY_TYPE_OPTIONS,
|
||||||
} from "@/uhm/lib/entityTypeOptions";
|
} from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||||
import {
|
import {
|
||||||
EntityFormState,
|
EntityFormState,
|
||||||
GeometryMetaFormState,
|
GeometryMetaFormState,
|
||||||
useEditorSessionState,
|
useEditorSessionState,
|
||||||
} from "@/uhm/lib/useEditorSessionState";
|
} from "@/uhm/lib/editor/state/useEditorSessionState";
|
||||||
import {
|
import {
|
||||||
getDefaultTypeIdForFeature,
|
getDefaultTypeIdForFeature,
|
||||||
normalizeFeatureBindingIds,
|
normalizeFeatureBindingIds,
|
||||||
@@ -46,25 +47,21 @@ 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,
|
||||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||||
import { useSectionCommands } from "@/uhm/lib/editor/section/useSectionCommands";
|
import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands";
|
||||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/geo/constants";
|
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||||
import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/timeline";
|
import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/timeline";
|
||||||
import { useFeatureCommands } from "./featureCommands";
|
import { useFeatureCommands } from "./featureCommands";
|
||||||
import { deleteSubmission } from "@/uhm/api/sections";
|
import { deleteSubmission } from "@/uhm/api/projects";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/UnifiedSearchBar";
|
import UnifiedSearchBar, { type UnifiedSearchKind } from "@/uhm/components/ui/UnifiedSearchBar";
|
||||||
|
|
||||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||||
const DEFAULT_EDITOR_USER_ID = "local-editor";
|
const DEFAULT_EDITOR_USER_ID = "local-editor";
|
||||||
@@ -93,11 +90,19 @@ export default function Page() {
|
|||||||
const entityFormStatusTimeoutRef = useRef<number | null>(null);
|
const entityFormStatusTimeoutRef = useRef<number | null>(null);
|
||||||
const geoBindingStatusTimeoutRef = useRef<number | null>(null);
|
const geoBindingStatusTimeoutRef = useRef<number | null>(null);
|
||||||
const [geoBindingStatus, setGeoBindingStatus] = useState<string | null>(null);
|
const [geoBindingStatus, setGeoBindingStatus] = useState<string | null>(null);
|
||||||
|
const [geometryFocusRequest, setGeometryFocusRequest] = useState<{
|
||||||
|
key: number;
|
||||||
|
collection: FeatureCollection;
|
||||||
|
} | 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,
|
||||||
@@ -107,21 +112,19 @@ export default function Page() {
|
|||||||
isOpeningSection,
|
isOpeningSection,
|
||||||
setIsOpeningSection,
|
setIsOpeningSection,
|
||||||
setAvailableSections,
|
setAvailableSections,
|
||||||
selectedSectionId,
|
selectedProjectId,
|
||||||
setSelectedSectionId,
|
setSelectedProjectId,
|
||||||
newSectionTitle,
|
newSectionTitle,
|
||||||
setNewSectionTitle,
|
setNewSectionTitle,
|
||||||
commitTitle,
|
commitTitle,
|
||||||
setCommitTitle,
|
setCommitTitle,
|
||||||
commitNote,
|
|
||||||
setCommitNote,
|
|
||||||
editorUserIdInput,
|
editorUserIdInput,
|
||||||
activeSection,
|
activeSection,
|
||||||
setActiveSection,
|
setActiveSection,
|
||||||
sectionState,
|
projectState,
|
||||||
setSectionState,
|
setProjectState,
|
||||||
sectionCommits,
|
sectionCommits,
|
||||||
setSectionCommits,
|
setProjectCommits,
|
||||||
baselineSnapshot,
|
baselineSnapshot,
|
||||||
setBaselineSnapshot,
|
setBaselineSnapshot,
|
||||||
entityCatalog,
|
entityCatalog,
|
||||||
@@ -130,8 +133,8 @@ export default function Page() {
|
|||||||
setSnapshotEntities,
|
setSnapshotEntities,
|
||||||
entityStatus,
|
entityStatus,
|
||||||
setEntityStatus,
|
setEntityStatus,
|
||||||
selectedFeatureId,
|
selectedFeatureIds,
|
||||||
setSelectedFeatureId,
|
setSelectedFeatureIds,
|
||||||
entityForm,
|
entityForm,
|
||||||
setEntityForm,
|
setEntityForm,
|
||||||
selectedGeometryEntityIds,
|
selectedGeometryEntityIds,
|
||||||
@@ -167,6 +170,12 @@ export default function Page() {
|
|||||||
const wikiSearchRequestRef = useRef(0);
|
const wikiSearchRequestRef = useRef(0);
|
||||||
const geoSearchRequestRef = useRef(0);
|
const geoSearchRequestRef = useRef(0);
|
||||||
|
|
||||||
|
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
|
||||||
|
const init: Record<string, boolean> = {};
|
||||||
|
for (const key of GEO_TYPE_KEYS) init[key] = true;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
|
||||||
const snapshotEntitiesRef = useRef(snapshotEntities);
|
const snapshotEntitiesRef = useRef(snapshotEntities);
|
||||||
const snapshotWikisRef = useRef(snapshotWikis);
|
const snapshotWikisRef = useRef(snapshotWikis);
|
||||||
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
|
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
|
||||||
@@ -235,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(() => {
|
||||||
@@ -253,72 +283,59 @@ 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;
|
||||||
}, [entities, snapshotEntitiesVisible]);
|
}, [entities, snapshotEntitiesVisible]);
|
||||||
const selectedFeature =
|
const selectedFeatures = useMemo(() => {
|
||||||
selectedFeatureId === null
|
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return [];
|
||||||
? null
|
return selectedFeatureIds
|
||||||
: editor.draft.features.find((feature) =>
|
.map(id => editor.draft.features.find(f => String(f.properties.id) === String(id)))
|
||||||
String(feature.properties.id) === String(selectedFeatureId)
|
.filter(Boolean) as Feature[];
|
||||||
) || null;
|
}, [selectedFeatureIds, editor.draft.features]);
|
||||||
|
|
||||||
|
const isMultiEditValid = useMemo(() => {
|
||||||
|
if (selectedFeatures.length <= 1) return true;
|
||||||
|
const firstShape = selectedFeatures[0].geometry.type;
|
||||||
|
return selectedFeatures.every(f => f.geometry.type === firstShape);
|
||||||
|
}, [selectedFeatures]);
|
||||||
|
|
||||||
|
const selectedFeature = selectedFeatures.length > 0 && isMultiEditValid ? selectedFeatures[0] : null;
|
||||||
|
|
||||||
const geometryChoices = useMemo(() => {
|
const geometryChoices = useMemo(() => {
|
||||||
|
const createdGeometryIds = new Set<string>();
|
||||||
|
for (const [id, change] of editor.changes.entries()) {
|
||||||
|
if (change.action === "create") createdGeometryIds.add(String(id));
|
||||||
|
}
|
||||||
|
|
||||||
const rows = (editor.draft.features || [])
|
const rows = (editor.draft.features || [])
|
||||||
.filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number"))
|
.filter((f) => f && f.properties && (typeof f.properties.id === "string" || typeof f.properties.id === "number"))
|
||||||
.map((f) => {
|
.map((f) => {
|
||||||
const id = String(f.properties.id);
|
const id = String(f.properties.id);
|
||||||
const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim();
|
const semantic = String(f.properties.type || getDefaultTypeIdForFeature(f) || "").trim();
|
||||||
const label = semantic.length ? `${semantic} (${f.geometry.type})` : f.geometry.type;
|
const label = semantic.length ? `${semantic} (${f.geometry.type})` : f.geometry.type;
|
||||||
return { id, label };
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
isNew: createdGeometryIds.has(id) || !editor.hasPersistedFeature(f.properties.id),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
rows.sort((a, b) => a.id.localeCompare(b.id));
|
rows.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
return rows;
|
return rows;
|
||||||
}, [editor.draft.features]);
|
}, [editor]);
|
||||||
|
|
||||||
const selectedGeometryBindingIds = useMemo(() => {
|
const selectedGeometryBindingIds = useMemo(() => {
|
||||||
if (!selectedFeature) return [];
|
if (!selectedFeature) return [];
|
||||||
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);
|
||||||
@@ -355,13 +372,13 @@ export default function Page() {
|
|||||||
+ (entitiesDirty ? 1 : 0)
|
+ (entitiesDirty ? 1 : 0)
|
||||||
+ (entityWikiDirty ? 1 : 0);
|
+ (entityWikiDirty ? 1 : 0);
|
||||||
|
|
||||||
const sectionCommands = useSectionCommands({
|
const sectionCommands = useProjectCommands({
|
||||||
editor,
|
editor,
|
||||||
editorUserId,
|
editorUserId,
|
||||||
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||||
activeSection,
|
activeSection,
|
||||||
sectionState,
|
projectState,
|
||||||
selectedSectionId,
|
selectedProjectId,
|
||||||
newSectionTitle,
|
newSectionTitle,
|
||||||
pendingSaveCount,
|
pendingSaveCount,
|
||||||
snapshotEntities,
|
snapshotEntities,
|
||||||
@@ -369,18 +386,17 @@ export default function Page() {
|
|||||||
snapshotEntityWikiLinks,
|
snapshotEntityWikiLinks,
|
||||||
baselineSnapshot,
|
baselineSnapshot,
|
||||||
commitTitle,
|
commitTitle,
|
||||||
commitNote,
|
|
||||||
setActiveSection,
|
setActiveSection,
|
||||||
setSelectedSectionId,
|
setSelectedProjectId,
|
||||||
setSectionState,
|
setProjectState,
|
||||||
setBaselineSnapshot,
|
setBaselineSnapshot,
|
||||||
setInitialData,
|
setInitialData,
|
||||||
setSectionCommits,
|
setProjectCommits,
|
||||||
setSnapshotEntities,
|
setSnapshotEntities,
|
||||||
setSnapshotWikis,
|
setSnapshotWikis,
|
||||||
setSnapshotEntityWikiLinks,
|
setSnapshotEntityWikiLinks,
|
||||||
setEntityFormStatus,
|
setEntityFormStatus,
|
||||||
setSelectedFeatureId,
|
setSelectedFeatureIds,
|
||||||
setEntityStatus,
|
setEntityStatus,
|
||||||
setIsSaving,
|
setIsSaving,
|
||||||
setIsSubmitting,
|
setIsSubmitting,
|
||||||
@@ -388,7 +404,6 @@ export default function Page() {
|
|||||||
setAvailableSections,
|
setAvailableSections,
|
||||||
setNewSectionTitle,
|
setNewSectionTitle,
|
||||||
setCommitTitle,
|
setCommitTitle,
|
||||||
setCommitNote,
|
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
openSectionForEditing,
|
openSectionForEditing,
|
||||||
@@ -397,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 {
|
||||||
@@ -680,14 +741,14 @@ export default function Page() {
|
|||||||
}, [geoSearchRequestRef, searchKind, searchQuery]);
|
}, [geoSearchRequestRef, searchKind, searchQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedFeatureId === null) return;
|
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
||||||
const stillExists = timelineVisibleDraft.features.some((feature) =>
|
const stillExistIds = selectedFeatureIds.filter(id =>
|
||||||
String(feature.properties.id) === String(selectedFeatureId)
|
timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id))
|
||||||
);
|
);
|
||||||
if (!stillExists) {
|
if (stillExistIds.length !== selectedFeatureIds.length) {
|
||||||
setSelectedFeatureId(null);
|
setSelectedFeatureIds(stillExistIds);
|
||||||
}
|
}
|
||||||
}, [timelineVisibleDraft, selectedFeatureId, setSelectedFeatureId]);
|
}, [timelineVisibleDraft, selectedFeatureIds, setSelectedFeatureIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFeature) {
|
if (!selectedFeature) {
|
||||||
@@ -802,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;
|
||||||
@@ -866,10 +923,14 @@ export default function Page() {
|
|||||||
}, [editor, flashEntityFormStatus]);
|
}, [editor, flashEntityFormStatus]);
|
||||||
|
|
||||||
const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => {
|
const handleToggleBindEntityForSelectedGeometry = useCallback((entityId: string, nextChecked: boolean) => {
|
||||||
if (!selectedFeature) {
|
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||||
flashEntityFormStatus("Chưa chọn geometry để bind entity.");
|
flashEntityFormStatus("Chưa chọn geometry để bind entity.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isMultiEditValid) {
|
||||||
|
flashEntityFormStatus("Không thể bind entity cho nhiều geometry khác loại.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = String(entityId || "").trim();
|
const id = String(entityId || "").trim();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const nextEntityIds = (() => {
|
const nextEntityIds = (() => {
|
||||||
@@ -886,9 +947,12 @@ export default function Page() {
|
|||||||
setIsEntitySubmitting(true);
|
setIsEntitySubmitting(true);
|
||||||
flashEntityFormStatus(null, 0);
|
flashEntityFormStatus(null, 0);
|
||||||
try {
|
try {
|
||||||
editor.patchFeatureProperties(
|
editor.patchFeaturePropertiesBatch(
|
||||||
selectedFeature.properties.id,
|
selectedFeatures.map((feature) => ({
|
||||||
buildFeatureEntityPatch(selectedFeature, nextEntityIds, entities)
|
id: feature.properties.id,
|
||||||
|
patch: buildFeatureEntityPatch(feature, nextEntityIds, entities),
|
||||||
|
})),
|
||||||
|
nextChecked ? "Bind entity vào GEO" : "Unbind entity khỏi GEO"
|
||||||
);
|
);
|
||||||
setSelectedGeometryEntityIds(nextEntityIds);
|
setSelectedGeometryEntityIds(nextEntityIds);
|
||||||
flashEntityFormStatus(
|
flashEntityFormStatus(
|
||||||
@@ -904,22 +968,33 @@ export default function Page() {
|
|||||||
editor,
|
editor,
|
||||||
entities,
|
entities,
|
||||||
flashEntityFormStatus,
|
flashEntityFormStatus,
|
||||||
selectedFeature,
|
selectedFeatures,
|
||||||
|
isMultiEditValid,
|
||||||
selectedGeometryEntityIds,
|
selectedGeometryEntityIds,
|
||||||
setIsEntitySubmitting,
|
setIsEntitySubmitting,
|
||||||
setSelectedGeometryEntityIds,
|
setSelectedGeometryEntityIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => {
|
const handleToggleBindGeometryForSelectedGeometry = useCallback((geoId: string, nextChecked: boolean) => {
|
||||||
if (!selectedFeature) {
|
if (!selectedFeatures || selectedFeatures.length === 0) {
|
||||||
flashGeoBindingStatus("Chưa chọn geometry để bind.");
|
flashGeoBindingStatus("Chưa chọn geometry để bind.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isMultiEditValid) {
|
||||||
|
flashGeoBindingStatus("Không thể bind geometry cho nhiều geometry khác loại.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = String(geoId || "").trim();
|
const id = String(geoId || "").trim();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (String(selectedFeature.properties.id) === id) return;
|
if (selectedFeatures.some(f => String(f.properties.id) === id)) return;
|
||||||
|
|
||||||
const prevBindingIds = normalizeFeatureBindingIds(selectedFeature);
|
|
||||||
|
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
flashGeoBindingStatus(null, 0);
|
||||||
|
try {
|
||||||
|
const bindingPatches = selectedFeatures.map((feature) => {
|
||||||
|
const prevBindingIds = normalizeFeatureBindingIds(feature);
|
||||||
const has = prevBindingIds.includes(id);
|
const has = prevBindingIds.includes(id);
|
||||||
const nextBindingIds = (() => {
|
const nextBindingIds = (() => {
|
||||||
if (nextChecked) {
|
if (nextChecked) {
|
||||||
@@ -929,12 +1004,24 @@ export default function Page() {
|
|||||||
if (!has) return prevBindingIds;
|
if (!has) return prevBindingIds;
|
||||||
return prevBindingIds.filter((x) => x !== id);
|
return prevBindingIds.filter((x) => x !== id);
|
||||||
})();
|
})();
|
||||||
|
return {
|
||||||
|
id: feature.properties.id,
|
||||||
|
patch: { binding: nextBindingIds },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
editor.patchFeaturePropertiesBatch(
|
||||||
|
bindingPatches,
|
||||||
|
nextChecked ? "Bind geometry vào GEO" : "Unbind geometry khỏi GEO"
|
||||||
|
);
|
||||||
|
|
||||||
setIsEntitySubmitting(true);
|
// Assume selectedFeature (the first one) reflects the representative binding in UI
|
||||||
flashGeoBindingStatus(null, 0);
|
const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]);
|
||||||
try {
|
const firstFeatureHas = firstFeaturePrevBindings.includes(id);
|
||||||
editor.patchFeatureProperties(selectedFeature.properties.id, { binding: nextBindingIds });
|
const nextBindingIdsForUI = (() => {
|
||||||
setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIds.join(", ") }));
|
if (nextChecked) return firstFeatureHas ? firstFeaturePrevBindings : [...firstFeaturePrevBindings, id];
|
||||||
|
return firstFeatureHas ? firstFeaturePrevBindings.filter(x => x !== id) : firstFeaturePrevBindings;
|
||||||
|
})();
|
||||||
|
setGeometryMetaForm((prev) => ({ ...prev, binding: nextBindingIdsForUI.join(", ") }));
|
||||||
flashGeoBindingStatus(
|
flashGeoBindingStatus(
|
||||||
nextChecked
|
nextChecked
|
||||||
? "Đã bind geometry vào binding. Commit khi sẵn sàng."
|
? "Đã bind geometry vào binding. Commit khi sẵn sàng."
|
||||||
@@ -947,11 +1034,46 @@ export default function Page() {
|
|||||||
}, [
|
}, [
|
||||||
editor,
|
editor,
|
||||||
flashGeoBindingStatus,
|
flashGeoBindingStatus,
|
||||||
selectedFeature,
|
selectedFeatures,
|
||||||
|
isMultiEditValid,
|
||||||
setGeometryMetaForm,
|
setGeometryMetaForm,
|
||||||
setIsEntitySubmitting,
|
setIsEntitySubmitting,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleFocusGeometryFromBindingPanel = useCallback((geoId: string) => {
|
||||||
|
const id = String(geoId || "").trim();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const feature = editor.draft.features.find((item) => String(item.properties.id) === id) || null;
|
||||||
|
if (!feature) {
|
||||||
|
flashGeoBindingStatus("Không tìm thấy geometry để zoom.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleInCurrentTimeline = timelineVisibleDraft.features.some(
|
||||||
|
(item) => String(item.properties.id) === id
|
||||||
|
);
|
||||||
|
if (timelineFilterEnabled && !visibleInCurrentTimeline) {
|
||||||
|
setTimelineFilterEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFeatureIds([feature.properties.id]);
|
||||||
|
setGeometryFocusRequest((prev) => ({
|
||||||
|
key: (prev?.key ?? 0) + 1,
|
||||||
|
collection: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [feature],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [
|
||||||
|
editor.draft.features,
|
||||||
|
flashGeoBindingStatus,
|
||||||
|
setSelectedFeatureIds,
|
||||||
|
setTimelineFilterEnabled,
|
||||||
|
timelineFilterEnabled,
|
||||||
|
timelineVisibleDraft.features,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleAddWikiRefToProject = useCallback((wiki: Wiki) => {
|
const handleAddWikiRefToProject = useCallback((wiki: Wiki) => {
|
||||||
const id = String(wiki.id || "").trim();
|
const id = String(wiki.id || "").trim();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -983,18 +1105,19 @@ 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) {
|
||||||
setSelectedFeatureId(existing.properties.id);
|
// Keep entity store consistent: importing/selecting a geo implies the entity should exist in snapshot + catalog.
|
||||||
|
handleAddEntityRefToProject(importedEntity);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -1006,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",
|
||||||
@@ -1024,20 +1147,46 @@ export default function Page() {
|
|||||||
geometry,
|
geometry,
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.createFeature(feature);
|
editor.createFeatureWithSnapshotEntities(
|
||||||
setSelectedFeatureId(feature.properties.id);
|
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]);
|
||||||
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,
|
||||||
setSelectedFeatureId,
|
setEntityCatalog,
|
||||||
|
setSelectedFeatureIds,
|
||||||
setTimelineFilterEnabled,
|
setTimelineFilterEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const featureCommands = useFeatureCommands({
|
const featureCommands = useFeatureCommands({
|
||||||
editor,
|
editor,
|
||||||
selectedFeature,
|
selectedFeatures,
|
||||||
geometryMetaForm,
|
geometryMetaForm,
|
||||||
setGeometryMetaForm,
|
setGeometryMetaForm,
|
||||||
selectedGeometryEntityIds,
|
selectedGeometryEntityIds,
|
||||||
@@ -1089,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 || []) {
|
||||||
@@ -1111,17 +1261,19 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const headCommit = sectionState?.head_commit_id
|
const headCommit = projectState?.head_commit_id
|
||||||
? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null
|
? sectionCommits.find((commit) => commit.id === projectState.head_commit_id) || null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleCreateFeature = (feature: Feature) => {
|
const handleCreateFeature = (feature: Feature) => {
|
||||||
editor.createFeature(feature);
|
editor.createFeature(feature);
|
||||||
setSelectedFeatureId(feature.properties.id);
|
setSelectedFeatureIds([feature.properties.id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
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}
|
||||||
@@ -1133,20 +1285,16 @@ export default function Page() {
|
|||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
sectionTitle={activeSection?.title || "Đang tải project"}
|
sectionTitle={activeSection?.title || "Đang tải project"}
|
||||||
sectionStatus={sectionState?.status || "editing"}
|
projectStatus={projectState?.status || "editing"}
|
||||||
commitTitle={commitTitle}
|
commitTitle={commitTitle}
|
||||||
commitNote={commitNote}
|
|
||||||
onCommitTitleChange={setCommitTitle}
|
onCommitTitleChange={setCommitTitle}
|
||||||
onCommitNoteChange={setCommitNote}
|
|
||||||
commitCount={sectionCommits.length}
|
commitCount={sectionCommits.length}
|
||||||
hasHeadCommit={Boolean(sectionState?.head_commit_id)}
|
hasHeadCommit={Boolean(projectState?.head_commit_id)}
|
||||||
headCommitId={sectionState?.head_commit_id || null}
|
headCommitId={projectState?.head_commit_id || null}
|
||||||
latestCommitLabel={headCommit ? `Head: ${formatCommitTitle(headCommit)}` : null}
|
latestCommitLabel={headCommit ? `Head: ${formatCommitTitle(headCommit)}` : null}
|
||||||
commits={sectionCommits}
|
commits={sectionCommits}
|
||||||
changesCount={pendingSaveCount}
|
changesCount={pendingSaveCount}
|
||||||
undoStack={editor.undoStack}
|
undoStack={editor.undoStack}
|
||||||
createdEntities={createdEntities}
|
|
||||||
createdGeometries={createdGeometries}
|
|
||||||
width={leftPanelWidth}
|
width={leftPanelWidth}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1156,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" }}>
|
||||||
@@ -1204,18 +1354,27 @@ export default function Page() {
|
|||||||
{isBackgroundVisibilityReady ? (
|
{isBackgroundVisibilityReady ? (
|
||||||
<Map
|
<Map
|
||||||
mode={mode}
|
mode={mode}
|
||||||
|
onSetMode={setMode}
|
||||||
draft={timelineVisibleDraft}
|
draft={timelineVisibleDraft}
|
||||||
selectedFeatureId={selectedFeatureId}
|
labelContextDraft={editor.draft}
|
||||||
onSelectFeatureId={setSelectedFeatureId}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
|
onSelectFeatureIds={setSelectedFeatureIds}
|
||||||
onCreateFeature={handleCreateFeature}
|
onCreateFeature={handleCreateFeature}
|
||||||
onDeleteFeature={editor.deleteFeature}
|
onDeleteFeature={editor.deleteFeature}
|
||||||
onUpdateFeature={editor.updateFeature}
|
onUpdateFeature={editor.updateFeature}
|
||||||
backgroundVisibility={backgroundVisibility}
|
backgroundVisibility={backgroundVisibility}
|
||||||
|
geometryVisibility={effectiveGeometryVisibility}
|
||||||
respectBindingFilter={geometryBindingFilterEnabled}
|
respectBindingFilter={geometryBindingFilterEnabled}
|
||||||
|
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
||||||
|
focusRequestKey={geometryFocusRequest?.key ?? null}
|
||||||
|
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}
|
||||||
@@ -1225,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) => {
|
||||||
@@ -1244,6 +1406,10 @@ export default function Page() {
|
|||||||
onToggleLayer={handleToggleBackgroundLayer}
|
onToggleLayer={handleToggleBackgroundLayer}
|
||||||
onShowAll={handleShowAllBackgroundLayers}
|
onShowAll={handleShowAllBackgroundLayers}
|
||||||
onHideAll={handleHideAllBackgroundLayers}
|
onHideAll={handleHideAllBackgroundLayers}
|
||||||
|
geometryVisibility={geometryVisibility}
|
||||||
|
onToggleGeometryType={(typeKey) => {
|
||||||
|
setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false }));
|
||||||
|
}}
|
||||||
width={rightPanelWidth}
|
width={rightPanelWidth}
|
||||||
topContent={
|
topContent={
|
||||||
<div style={{ display: "grid", gap: "12px" }}>
|
<div style={{ display: "grid", gap: "12px" }}>
|
||||||
@@ -1411,8 +1577,8 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{Array.isArray(item.geometries) && item.geometries.length ? (
|
{Array.isArray(item.geometries) && item.geometries.length ? (
|
||||||
<div style={{ display: "grid", gap: 6 }}>
|
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
|
||||||
{item.geometries.slice(0, 4).map((geo) => (
|
{item.geometries.map((geo) => (
|
||||||
<div
|
<div
|
||||||
key={geo.id}
|
key={geo.id}
|
||||||
style={{
|
style={{
|
||||||
@@ -1431,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 ?? "?"}`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -1457,11 +1623,6 @@ export default function Page() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{item.geometries.length > 4 ? (
|
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>
|
|
||||||
+{item.geometries.length - 4} more…
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>
|
||||||
@@ -1481,6 +1642,7 @@ export default function Page() {
|
|||||||
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
|
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
|
||||||
selectedGeometryBindingIds={selectedGeometryBindingIds}
|
selectedGeometryBindingIds={selectedGeometryBindingIds}
|
||||||
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
|
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
|
||||||
|
onFocusGeometry={handleFocusGeometryFromBindingPanel}
|
||||||
statusText={geoBindingStatus}
|
statusText={geoBindingStatus}
|
||||||
bindingFilterEnabled={geometryBindingFilterEnabled}
|
bindingFilterEnabled={geometryBindingFilterEnabled}
|
||||||
onBindingFilterEnabledChange={setGeometryBindingFilterEnabled}
|
onBindingFilterEnabledChange={setGeometryBindingFilterEnabled}
|
||||||
@@ -1515,21 +1677,8 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
{!wikiOnly && selectedFeature ? (
|
{!wikiOnly && selectedFeature ? (
|
||||||
<SelectedGeometryPanel
|
<SelectedGeometryPanel
|
||||||
selectedFeature={selectedFeature}
|
selectedFeatures={selectedFeatures}
|
||||||
selectedFeatureEntitySummary={
|
entityTypeOptions={GEOMETRY_TYPE_OPTIONS}
|
||||||
selectedFeature
|
|
||||||
? formatEntityNamesForDisplay(selectedFeature, entities)
|
|
||||||
: "Chưa gắn"
|
|
||||||
}
|
|
||||||
selectedFeatureBindingSummary={
|
|
||||||
selectedFeature
|
|
||||||
? formatBindingIdsForDisplay(selectedFeature)
|
|
||||||
: "Không có"
|
|
||||||
}
|
|
||||||
entities={entities}
|
|
||||||
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
|
||||||
onEntityIdsChange={handleEntityIdsChange}
|
|
||||||
entityTypeOptions={ENTITY_TYPE_OPTIONS}
|
|
||||||
geometryMetaForm={geometryMetaForm}
|
geometryMetaForm={geometryMetaForm}
|
||||||
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
||||||
isEntitySubmitting={isEntitySubmitting}
|
isEntitySubmitting={isEntitySubmitting}
|
||||||
@@ -1540,6 +1689,8 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1602,7 +1753,7 @@ function normalizeEditorUserId(value: string): string {
|
|||||||
return normalized || DEFAULT_EDITOR_USER_ID;
|
return normalized || DEFAULT_EDITOR_USER_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCommitTitle(commit: SectionCommit): string {
|
function formatCommitTitle(commit: ProjectCommit): string {
|
||||||
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+664
-59
@@ -1,54 +1,114 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Map from "@/uhm/components/Map";
|
|
||||||
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
|
import Map, { type MapHoverPayload } from "@/uhm/components/Map";
|
||||||
import TimelineBar from "@/uhm/components/TimelineBar";
|
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
||||||
|
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||||
|
import { fetchEntities, type Entity } from "@/uhm/api/entities";
|
||||||
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
||||||
import { ApiError } from "@/uhm/api/http";
|
import { ApiError } from "@/uhm/api/http";
|
||||||
import { API_BASE_URL } from "@/uhm/api/config";
|
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||||
import {
|
import {
|
||||||
BackgroundLayerId,
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
BackgroundLayerVisibility,
|
type BackgroundLayerId,
|
||||||
|
type BackgroundLayerVisibility,
|
||||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
} from "@/uhm/lib/backgroundLayers";
|
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
import {
|
import {
|
||||||
loadBackgroundLayerVisibilityFromStorage,
|
loadBackgroundLayerVisibilityFromStorage,
|
||||||
persistBackgroundLayerVisibility,
|
persistBackgroundLayerVisibility,
|
||||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||||
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants";
|
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/map/geo/constants";
|
||||||
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline";
|
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||||
import type { Feature, FeatureCollection } from "@/uhm/types/geo";
|
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/utils/timeline";
|
||||||
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
|
||||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||||
|
const ENTITY_PAGE_LIMIT = 100;
|
||||||
|
const WIKI_PAGE_LIMIT = 100;
|
||||||
|
const RELATION_CONCURRENCY = 6;
|
||||||
|
|
||||||
|
type RelationIndex = {
|
||||||
|
entitiesById: Record<string, Entity>;
|
||||||
|
entityGeometriesById: Record<string, FeatureCollection>;
|
||||||
|
entityWikisById: Record<string, Wiki[]>;
|
||||||
|
geometryEntityIds: Record<string, string[]>;
|
||||||
|
wikiEntityIdsBySlug: Record<string, string[]>;
|
||||||
|
wikiBySlug: Record<string, Wiki>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinkEntityPopupState = {
|
||||||
|
slug: string;
|
||||||
|
entities: Entity[];
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_RELATIONS: RelationIndex = {
|
||||||
|
entitiesById: {},
|
||||||
|
entityGeometriesById: {},
|
||||||
|
entityWikisById: {},
|
||||||
|
geometryEntityIds: {},
|
||||||
|
wikiEntityIdsBySlug: {},
|
||||||
|
wikiBySlug: {},
|
||||||
|
};
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
|
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
|
||||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]);
|
||||||
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
||||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
|
||||||
|
const [timeRange, setTimeRange] = useState<number>(0);
|
||||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||||
);
|
);
|
||||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
const timelineFetchRequestRef = useRef(0);
|
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
|
||||||
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null);
|
const init: Record<string, boolean> = {};
|
||||||
|
for (const key of GEO_TYPE_KEYS) init[key] = true;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
const [relations, setRelations] = useState<RelationIndex>(EMPTY_RELATIONS);
|
||||||
|
const [isRelationsLoading, setIsRelationsLoading] = useState(false);
|
||||||
|
const [relationsStatus, setRelationsStatus] = useState<string | null>(null);
|
||||||
|
const [relationsProgress, setRelationsProgress] = useState<{ completed: number; total: number }>({
|
||||||
|
completed: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
const [hoverAnchor, setHoverAnchor] = useState<MapHoverPayload | null>(null);
|
||||||
|
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
|
||||||
|
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
|
||||||
|
const [wikiCache, setWikiCache] = useState<Record<string, Wiki>>({});
|
||||||
|
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
|
||||||
|
const [activeWikiError, setActiveWikiError] = useState<string | null>(null);
|
||||||
|
const [linkEntityPopup, setLinkEntityPopup] = useState<LinkEntityPopupState | null>(null);
|
||||||
|
const [entityFocusToken, setEntityFocusToken] = useState(0);
|
||||||
|
|
||||||
const selectedFeature: Feature | null = useMemo(() => {
|
const timelineFetchRequestRef = useRef(0);
|
||||||
if (selectedFeatureId === null) return null;
|
const hoverHideTimerRef = useRef<number | null>(null);
|
||||||
|
const hoverPopupHoveredRef = useRef(false);
|
||||||
|
const linkEntityPopupRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const selectedFeature = useMemo(() => {
|
||||||
|
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null
|
data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureIds[0])) || null
|
||||||
);
|
);
|
||||||
}, [data.features, selectedFeatureId]);
|
}, [data.features, selectedFeatureIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedFeatureId === null) return;
|
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
||||||
const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId));
|
const stillExistIds = selectedFeatureIds.filter(id =>
|
||||||
if (!stillExists) setSelectedFeatureId(null);
|
data.features.some(feature => String(feature.properties.id) === String(id))
|
||||||
}, [data.features, selectedFeatureId]);
|
);
|
||||||
|
if (stillExistIds.length !== selectedFeatureIds.length) {
|
||||||
|
setSelectedFeatureIds(stillExistIds);
|
||||||
|
}
|
||||||
|
}, [data.features, selectedFeatureIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
@@ -70,10 +130,9 @@ export default function Page() {
|
|||||||
setIsTimelineLoading(true);
|
setIsTimelineLoading(true);
|
||||||
setTimelineStatus(null);
|
setTimelineStatus(null);
|
||||||
try {
|
try {
|
||||||
const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear });
|
const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange });
|
||||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||||
setData(next);
|
setData(next);
|
||||||
setLastLoadedAt(new Date().toISOString());
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
console.error("Load timeline data failed", err.body);
|
console.error("Load timeline data failed", err.body);
|
||||||
@@ -94,7 +153,107 @@ export default function Page() {
|
|||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
};
|
};
|
||||||
}, [timelineYear]);
|
}, [timelineYear, timeRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
async function loadRelations() {
|
||||||
|
setIsRelationsLoading(true);
|
||||||
|
setRelationsStatus(null);
|
||||||
|
setRelationsProgress({ completed: 0, total: 0 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entities = await fetchAllEntities();
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
const next: RelationIndex = {
|
||||||
|
entitiesById: {},
|
||||||
|
entityGeometriesById: {},
|
||||||
|
entityWikisById: {},
|
||||||
|
geometryEntityIds: {},
|
||||||
|
wikiEntityIdsBySlug: {},
|
||||||
|
wikiBySlug: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
next.entitiesById[entity.id] = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelationsProgress({ completed: 0, total: entities.length });
|
||||||
|
|
||||||
|
await mapWithConcurrency(entities, RELATION_CONCURRENCY, async (entity, index) => {
|
||||||
|
const [geometries, wikis] = await Promise.all([
|
||||||
|
fetchGeometriesByBBox({ ...WORLD_BBOX, entity_id: entity.id }),
|
||||||
|
fetchAllWikisForEntity(entity.id),
|
||||||
|
]);
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
next.entityGeometriesById[entity.id] = geometries;
|
||||||
|
next.entityWikisById[entity.id] = wikis;
|
||||||
|
|
||||||
|
for (const feature of geometries.features) {
|
||||||
|
pushUniqueString(next.geometryEntityIds, String(feature.properties.id), entity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const wiki of wikis) {
|
||||||
|
const slug = String(wiki.slug || "").trim();
|
||||||
|
if (!slug.length) continue;
|
||||||
|
next.wikiBySlug[slug] = wiki;
|
||||||
|
pushUniqueString(next.wikiEntityIdsBySlug, slug, entity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = index + 1;
|
||||||
|
if (completed === entities.length || completed % 5 === 0) {
|
||||||
|
setRelationsProgress({ completed, total: entities.length });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
normalizeRelationArrays(next.geometryEntityIds);
|
||||||
|
normalizeRelationArrays(next.wikiEntityIdsBySlug);
|
||||||
|
|
||||||
|
setRelations(next);
|
||||||
|
setWikiCache((prev) => ({ ...next.wikiBySlug, ...prev }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Load relation index failed", err);
|
||||||
|
if (!disposed) {
|
||||||
|
setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!disposed) {
|
||||||
|
setIsRelationsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRelations();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hoverEntityIds = useMemo(() => {
|
||||||
|
if (!hoverAnchor) return [];
|
||||||
|
return relations.geometryEntityIds[String(hoverAnchor.featureId)] || [];
|
||||||
|
}, [hoverAnchor, relations.geometryEntityIds]);
|
||||||
|
|
||||||
|
const hoverEntities = useMemo(() => {
|
||||||
|
return hoverEntityIds
|
||||||
|
.map((entityId) => relations.entitiesById[entityId] || null)
|
||||||
|
.filter((entity): entity is Entity => Boolean(entity));
|
||||||
|
}, [hoverEntityIds, relations.entitiesById]);
|
||||||
|
|
||||||
|
const activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null;
|
||||||
|
const activeEntityGeometries = activeEntityId
|
||||||
|
? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION
|
||||||
|
: EMPTY_FEATURE_COLLECTION;
|
||||||
|
|
||||||
|
const activeWiki = useMemo(() => {
|
||||||
|
if (!activeWikiSlug) return null;
|
||||||
|
return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
||||||
|
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
||||||
|
|
||||||
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
|
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
|
||||||
setBackgroundVisibility((prev) => {
|
setBackgroundVisibility((prev) => {
|
||||||
@@ -120,67 +279,513 @@ export default function Page() {
|
|||||||
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTimeRangeChange = (nextRange: number) => {
|
||||||
|
const safe = Number.isFinite(nextRange) ? Math.trunc(nextRange) : 0;
|
||||||
|
setTimeRange(Math.max(0, Math.min(30, safe)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearHoverHideTimer = useCallback(() => {
|
||||||
|
if (hoverHideTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hoverHideTimerRef.current);
|
||||||
|
hoverHideTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectEntity = useCallback((
|
||||||
|
entityId: string,
|
||||||
|
options?: {
|
||||||
|
sourceFeatureId?: string | number | null;
|
||||||
|
preferredWikiSlug?: string | null;
|
||||||
|
focusMap?: boolean;
|
||||||
|
selectGeometry?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const entity = relations.entitiesById[entityId] || null;
|
||||||
|
if (!entity) return;
|
||||||
|
|
||||||
|
const linkedWikis = relations.entityWikisById[entityId] || [];
|
||||||
|
const preferredWikiSlug = String(options?.preferredWikiSlug || "").trim();
|
||||||
|
const nextWikiSlug =
|
||||||
|
(preferredWikiSlug && linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug)
|
||||||
|
? preferredWikiSlug
|
||||||
|
: "") ||
|
||||||
|
linkedWikis.map((wiki) => String(wiki.slug || "").trim()).find((slug) => slug.length > 0) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
setActiveEntityId(entityId);
|
||||||
|
setActiveWikiSlug(nextWikiSlug);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
setLinkEntityPopup(null);
|
||||||
|
if (options?.focusMap !== false) {
|
||||||
|
setEntityFocusToken((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
if (options?.selectGeometry && options?.sourceFeatureId != null) {
|
||||||
|
setSelectedFeatureIds([options.sourceFeatureId]);
|
||||||
|
}
|
||||||
|
}, [relations.entitiesById, relations.entityWikisById]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
|
||||||
|
// For UI simplicity in viewer, just link to the first selected geometry
|
||||||
|
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
|
||||||
|
if (linkedEntityIds.length !== 1) return;
|
||||||
|
|
||||||
|
const onlyEntityId = linkedEntityIds[0];
|
||||||
|
if (activeEntityId === onlyEntityId) return;
|
||||||
|
|
||||||
|
selectEntity(onlyEntityId, {
|
||||||
|
sourceFeatureId: selectedFeatureIds[0],
|
||||||
|
focusMap: false,
|
||||||
|
selectGeometry: false,
|
||||||
|
});
|
||||||
|
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
|
||||||
|
|
||||||
|
const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => {
|
||||||
|
clearHoverHideTimer();
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
setHoverAnchor(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoverPopupHoveredRef.current) return;
|
||||||
|
hoverHideTimerRef.current = window.setTimeout(() => {
|
||||||
|
setHoverAnchor(null);
|
||||||
|
}, 120);
|
||||||
|
}, [clearHoverHideTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hoverHideTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hoverHideTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!linkEntityPopup) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setLinkEntityPopup(null);
|
||||||
|
};
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (target && linkEntityPopupRef.current?.contains(target)) return;
|
||||||
|
setLinkEntityPopup(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
};
|
||||||
|
}, [linkEntityPopup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeWikiSlug) {
|
||||||
|
setIsActiveWikiLoading(false);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
|
||||||
|
if (cached?.content) {
|
||||||
|
setIsActiveWikiLoading(false);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
(async () => {
|
||||||
|
setIsActiveWikiLoading(true);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
try {
|
||||||
|
const row = await fetchWikiBySlug(activeWikiSlug);
|
||||||
|
if (disposed) return;
|
||||||
|
if (row) {
|
||||||
|
setWikiCache((prev) => ({ ...prev, [activeWikiSlug]: row }));
|
||||||
|
} else {
|
||||||
|
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed) return;
|
||||||
|
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
|
||||||
|
} finally {
|
||||||
|
if (!disposed) setIsActiveWikiLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
|
||||||
|
|
||||||
|
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
|
||||||
|
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
|
||||||
|
const linkedEntities = linkedEntityIds
|
||||||
|
.map((entityId) => relations.entitiesById[entityId] || null)
|
||||||
|
.filter((entity): entity is Entity => Boolean(entity));
|
||||||
|
|
||||||
|
if (linkedEntities.length === 1) {
|
||||||
|
selectEntity(linkedEntities[0].id, { preferredWikiSlug: slug });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wikiCache[slug] && !relations.wikiBySlug[slug]) {
|
||||||
|
try {
|
||||||
|
const row = await fetchWikiBySlug(slug);
|
||||||
|
if (row) setWikiCache((prev) => ({ ...prev, [slug]: row }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Load wiki by slug failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!linkedEntities.length) return;
|
||||||
|
|
||||||
|
const popupWidth = 240;
|
||||||
|
const popupHeight = Math.min(240, linkedEntities.length * 44 + 20);
|
||||||
|
const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight);
|
||||||
|
|
||||||
|
setLinkEntityPopup({
|
||||||
|
slug,
|
||||||
|
entities: linkedEntities,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
});
|
||||||
|
}, [relations.entitiesById, relations.wikiBySlug, relations.wikiEntityIdsBySlug, selectEntity, wikiCache]);
|
||||||
|
|
||||||
|
const helperText = isRelationsLoading
|
||||||
|
? `Đang index entity/wiki ${relationsProgress.completed}/${relationsProgress.total || "?"}`
|
||||||
|
: relationsStatus || `Features: ${data.features.length}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
<div className="relative min-h-screen overflow-hidden bg-gray-950 text-gray-100">
|
||||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
<div className="relative min-h-screen">
|
||||||
{isBackgroundVisibilityReady ? (
|
{isBackgroundVisibilityReady ? (
|
||||||
<Map
|
<Map
|
||||||
mode="select"
|
mode="select"
|
||||||
draft={data}
|
draft={data}
|
||||||
selectedFeatureId={selectedFeatureId}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
onSelectFeatureId={setSelectedFeatureId}
|
onSelectFeatureIds={setSelectedFeatureIds}
|
||||||
backgroundVisibility={backgroundVisibility}
|
backgroundVisibility={backgroundVisibility}
|
||||||
|
geometryVisibility={geometryVisibility}
|
||||||
allowGeometryEditing={false}
|
allowGeometryEditing={false}
|
||||||
respectBindingFilter={false}
|
respectBindingFilter={true}
|
||||||
|
onHoverFeatureChange={handleMapHoverChange}
|
||||||
|
highlightFeatures={activeEntityGeometries}
|
||||||
|
focusFeatureCollection={activeEntityGeometries}
|
||||||
|
focusRequestKey={entityFocusToken}
|
||||||
|
focusPadding={activeEntityId ? { top: 84, right: 500, bottom: 116, left: 84 } : { top: 84, right: 84, bottom: 116, left: 84 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
<div className="h-screen w-full bg-[#0b1220]" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TimelineBar
|
<TimelineBar
|
||||||
year={timelineDraftYear}
|
year={timelineDraftYear}
|
||||||
onYearChange={handleTimelineYearChange}
|
onYearChange={handleTimelineYearChange}
|
||||||
|
timeRange={timeRange}
|
||||||
|
onTimeRangeChange={handleTimeRangeChange}
|
||||||
isLoading={isTimelineLoading}
|
isLoading={isTimelineLoading}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
statusText={timelineStatus}
|
statusText={timelineStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="absolute left-4 top-4 z-20 w-[280px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-slate-950/92 shadow-xl backdrop-blur">
|
||||||
|
<div className="border-b border-white/10 px-4 py-3">
|
||||||
|
<div className="text-sm font-semibold text-white">Map Layers</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-400">{helperText}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackgroundLayersPanel
|
<div className="grid gap-4 px-4 py-4">
|
||||||
visibility={backgroundVisibility}
|
<div>
|
||||||
onToggleLayer={handleToggleBackgroundLayer}
|
<div className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.08em] text-slate-500">
|
||||||
onShowAll={handleShowAllBackgroundLayers}
|
<span>Background</span>
|
||||||
onHideAll={handleHideAllBackgroundLayers}
|
<div className="flex gap-2">
|
||||||
topContent={
|
<button type="button" onClick={handleShowAllBackgroundLayers} className="text-slate-300 transition hover:text-white">
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleHideAllBackgroundLayers} className="text-slate-300 transition hover:text-white">
|
||||||
|
Off
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
|
||||||
|
const active = Boolean(backgroundVisibility[layer.id]);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={layer.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleBackgroundLayer(layer.id)}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition ${active
|
||||||
|
? "border-sky-400/40 bg-sky-500/10 text-sky-200"
|
||||||
|
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{layer.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[11px] uppercase tracking-[0.08em] text-slate-500">
|
||||||
|
Geometry
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{GEO_TYPE_KEYS.map((typeKey) => {
|
||||||
|
const active = geometryVisibility[typeKey] !== false;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={typeKey}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setGeometryVisibility((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[typeKey]: prev[typeKey] === false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${active
|
||||||
|
? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{typeKey.replaceAll("_", " ")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hoverAnchor && hoverEntities.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
|
className="absolute z-30 w-[320px] max-w-[calc(100vw-2rem)]"
|
||||||
style={{
|
style={{
|
||||||
padding: "10px",
|
left: clampNumber(hoverAnchor.point.x + 18, 16, typeof window !== "undefined" ? window.innerWidth - 340 : hoverAnchor.point.x + 18),
|
||||||
background: "#0b1220",
|
top: clampNumber(hoverAnchor.point.y - 8, 16, typeof window !== "undefined" ? window.innerHeight - 280 : hoverAnchor.point.y - 8),
|
||||||
borderRadius: "8px",
|
}}
|
||||||
border: "1px solid #1f2937",
|
onMouseEnter={() => {
|
||||||
display: "grid",
|
hoverPopupHoveredRef.current = true;
|
||||||
gap: "8px",
|
clearHoverHideTimer();
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
hoverPopupHoveredRef.current = false;
|
||||||
|
setHoverAnchor(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>Viewer</div>
|
<div className="overflow-hidden rounded-xl border border-white/10 bg-slate-950/95 shadow-xl backdrop-blur">
|
||||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
{hoverEntities.length > 1 ? (
|
||||||
API: {API_BASE_URL}
|
<div className="border-b border-white/10 px-4 py-3">
|
||||||
</div>
|
<div className="text-sm font-semibold text-white">Related Entities</div>
|
||||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
<div className="mt-1 text-xs text-slate-400">
|
||||||
Year: {timelineYear} | Features: {data.features.length}
|
Geometry #{String(hoverAnchor.featureId)}
|
||||||
</div>
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
|
||||||
{isTimelineLoading ? "Loading geometries..." : lastLoadedAt ? `Loaded: ${lastLoadedAt}` : "Not loaded yet"}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#cbd5e1", fontSize: "13px", overflowWrap: "anywhere" }}>
|
|
||||||
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
|
||||||
{selectedFeature?.properties?.type ? `Type: ${String(selectedFeature.properties.type)}` : "Type: -"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
) : null}
|
||||||
|
<div className="max-h-[252px] overflow-y-auto">
|
||||||
|
<div className="grid gap-2 p-3">
|
||||||
|
{hoverEntities.map((entity) => (
|
||||||
|
<button
|
||||||
|
key={entity.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
selectEntity(entity.id, {
|
||||||
|
sourceFeatureId: hoverAnchor.featureId,
|
||||||
|
focusMap: true,
|
||||||
|
selectGeometry: true,
|
||||||
|
});
|
||||||
|
setHoverAnchor(null);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/[0.03] px-3 py-3 text-left transition hover:border-sky-400/40 hover:bg-sky-500/10"
|
||||||
|
>
|
||||||
|
<div className="truncate text-sm font-semibold text-white">
|
||||||
|
{entity.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-1 text-xs leading-5 text-slate-400"
|
||||||
|
style={{
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entity.description?.trim() || "Không có mô tả."}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeEntity ? (
|
||||||
|
<aside className="absolute bottom-4 right-4 top-4 z-20 w-[420px] max-w-[calc(100vw-2rem)]">
|
||||||
|
<PublicWikiSidebar
|
||||||
|
entity={activeEntity}
|
||||||
|
wiki={activeWiki}
|
||||||
|
isLoading={isActiveWikiLoading}
|
||||||
|
error={activeWikiError}
|
||||||
|
onClose={() => {
|
||||||
|
setActiveEntityId(null);
|
||||||
|
setActiveWikiSlug(null);
|
||||||
|
setActiveWikiError(null);
|
||||||
|
setLinkEntityPopup(null);
|
||||||
|
}}
|
||||||
|
onWikiLinkRequest={handleWikiLinkRequest}
|
||||||
/>
|
/>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkEntityPopup ? (
|
||||||
|
<div
|
||||||
|
ref={linkEntityPopupRef}
|
||||||
|
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
|
||||||
|
style={{ top: linkEntityPopup.top, left: linkEntityPopup.left }}
|
||||||
|
>
|
||||||
|
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Related Entities
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
/wiki/{linkEntityPopup.slug}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[220px] overflow-y-auto p-2">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{linkEntityPopup.entities.map((entity) => (
|
||||||
|
<button
|
||||||
|
key={entity.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
|
||||||
|
setLinkEntityPopup(null);
|
||||||
|
}}
|
||||||
|
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{entity.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAllEntities(): Promise<Entity[]> {
|
||||||
|
const items: Entity[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cursor: string | undefined;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const page = await fetchEntities({ q: "", limit: ENTITY_PAGE_LIMIT, cursor });
|
||||||
|
if (!page.length) break;
|
||||||
|
|
||||||
|
for (const entity of page) {
|
||||||
|
if (!entity?.id || seen.has(entity.id)) continue;
|
||||||
|
seen.add(entity.id);
|
||||||
|
items.push(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.length < ENTITY_PAGE_LIMIT) break;
|
||||||
|
const nextCursor = page[page.length - 1]?.id;
|
||||||
|
if (!nextCursor || nextCursor === cursor) break;
|
||||||
|
cursor = nextCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllWikisForEntity(entityId: string): Promise<Wiki[]> {
|
||||||
|
const items: Wiki[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cursor: string | undefined;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const page = await searchWikisByTitle("", {
|
||||||
|
entityId,
|
||||||
|
limit: WIKI_PAGE_LIMIT,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
|
if (!page.length) break;
|
||||||
|
|
||||||
|
for (const wiki of page) {
|
||||||
|
if (!wiki?.id || seen.has(wiki.id)) continue;
|
||||||
|
seen.add(wiki.id);
|
||||||
|
items.push(wiki);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.length < WIKI_PAGE_LIMIT) break;
|
||||||
|
const nextCursor = page[page.length - 1]?.id;
|
||||||
|
if (!nextCursor || nextCursor === cursor) break;
|
||||||
|
cursor = nextCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapWithConcurrency<T>(
|
||||||
|
items: T[],
|
||||||
|
concurrency: number,
|
||||||
|
worker: (item: T, index: number) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
const runnerCount = Math.max(1, Math.min(concurrency, items.length));
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: runnerCount }, async () => {
|
||||||
|
while (true) {
|
||||||
|
const current = nextIndex++;
|
||||||
|
if (current >= items.length) return;
|
||||||
|
await worker(items[current], current);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUniqueString(target: Record<string, string[]>, key: string, value: string) {
|
||||||
|
if (!target[key]) {
|
||||||
|
target[key] = [value];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!target[key].includes(value)) {
|
||||||
|
target[key].push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelationArrays(target: Record<string, string[]>) {
|
||||||
|
for (const key of Object.keys(target)) {
|
||||||
|
target[key] = Array.from(new Set(target[key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNumber(value: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(value)) return min;
|
||||||
|
if (value < min) return min;
|
||||||
|
if (value > max) return max;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) {
|
||||||
|
const margin = 12;
|
||||||
|
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
|
||||||
|
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
|
||||||
|
const preferredLeft = rect.right + margin;
|
||||||
|
const maxLeft = Math.max(margin, viewportWidth - width - margin);
|
||||||
|
const left = Math.min(preferredLeft, maxLeft);
|
||||||
|
|
||||||
|
const preferredTop = rect.top;
|
||||||
|
const maxTop = Math.max(margin, viewportHeight - height - margin);
|
||||||
|
const top = Math.max(margin, Math.min(preferredTop, maxTop));
|
||||||
|
|
||||||
|
return { top, left };
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function LandingPage() {
|
|||||||
{
|
{
|
||||||
name: "Trần Anh Đức",
|
name: "Trần Anh Đức",
|
||||||
role: "Project Manager",
|
role: "Project Manager",
|
||||||
desc: "Đẹp trai cao m8",
|
desc: "Fan cứng anh Lại Ngứa Chân",
|
||||||
avatar: "/images/teamdev/tad.jpeg",
|
avatar: "/images/teamdev/tad.jpeg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import Badge from "@/components/ui/badge/Badge";
|
|||||||
import { CreateProjectPayload, Project } from "@/interface/project";
|
import { CreateProjectPayload, Project } from "@/interface/project";
|
||||||
import { apiCreateProject, apiCreateProjectCommit, apiGetProjectCommits, getCurrentProject } from "@/service/projectService";
|
import { apiCreateProject, apiCreateProjectCommit, apiGetProjectCommits, getCurrentProject } from "@/service/projectService";
|
||||||
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
import type { EditorSnapshot } from "@/uhm/types/sections";
|
import type { EditorSnapshot } from "@/uhm/types/projects";
|
||||||
|
|
||||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import "yet-another-react-lightbox/styles.css";
|
|||||||
import "yet-another-react-lightbox/plugins/captions.css";
|
import "yet-another-react-lightbox/plugins/captions.css";
|
||||||
import { createHistorianCV } from "@/service/historianService";
|
import { createHistorianCV } from "@/service/historianService";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { newId } from "@/uhm/lib/id";
|
import { newId } from "@/uhm/lib/utils/id";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
import { PresignedUrlResponse } from "@/interface/media";
|
import { PresignedUrlResponse } from "@/interface/media";
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export default function WikiEditorPage() {
|
|||||||
type: "doc",
|
type: "doc",
|
||||||
content: [
|
content: [
|
||||||
{ type: "paragraph", content: [{ type: "text", text: "Write your wiki content here." }] },
|
{ type: "paragraph", content: [{ type: "text", text: "Write your wiki content here." }] },
|
||||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Section" }] },
|
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Project" }] },
|
||||||
{ type: "paragraph", content: [{ type: "text", text: "Use H1/H2/H3 and the TOC will follow." }] },
|
{ type: "paragraph", content: [{ type: "text", text: "Use H1/H2/H3 and the TOC will follow." }] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import WikiBySlugClient from "./wiki-by-slug-client";
|
||||||
|
|
||||||
|
export default async function WikiBySlugPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }> | { slug: string };
|
||||||
|
}) {
|
||||||
|
const resolved = await params;
|
||||||
|
return <WikiBySlugClient slug={resolved.slug} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,822 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
|
||||||
|
import { ApiError } from "@/uhm/api/http";
|
||||||
|
import { fetchWikiBySlug, getContentByVersionWikiId, type Wiki } from "@/uhm/api/wikis";
|
||||||
|
|
||||||
|
type TocItem = {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tiptapJsonToPlainText(node: unknown): string {
|
||||||
|
if (node == null) return "";
|
||||||
|
if (typeof node === "string") return node;
|
||||||
|
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
|
||||||
|
|
||||||
|
if (isRecord(node)) {
|
||||||
|
if (node.type === "text" && typeof node.text === "string") return node.text;
|
||||||
|
if (node.type === "hardBreak") return "\n";
|
||||||
|
if ("content" in node) return tiptapJsonToPlainText(node.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(input: string): string {
|
||||||
|
return input
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
|
||||||
|
const value = String(raw || "").trim();
|
||||||
|
if (!value.length) return "";
|
||||||
|
|
||||||
|
// New format: HTML string.
|
||||||
|
if (value[0] === "<") return value;
|
||||||
|
|
||||||
|
// Legacy format: Tiptap JSON string.
|
||||||
|
if (value[0] === "{") {
|
||||||
|
try {
|
||||||
|
const json: unknown = JSON.parse(value);
|
||||||
|
const text = tiptapJsonToPlainText(json).trim();
|
||||||
|
if (!text.length) return "";
|
||||||
|
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown plaintext: treat as plain text.
|
||||||
|
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifyHeading(raw: string): string {
|
||||||
|
const input = String(raw || "").trim();
|
||||||
|
if (!input.length) return "";
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalHref(href: string): boolean {
|
||||||
|
const h = href.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
h.startsWith("http://") ||
|
||||||
|
h.startsWith("https://") ||
|
||||||
|
h.startsWith("mailto:") ||
|
||||||
|
h.startsWith("tel:") ||
|
||||||
|
h.startsWith("sms:")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteHtmlAndBuildToc(inputHtml: string, wikiBaseUrl: string): { html: string; toc: TocItem[] } {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(inputHtml, "text/html");
|
||||||
|
|
||||||
|
// Basic hardening: do not render scripts in user content.
|
||||||
|
for (const el of Array.from(doc.querySelectorAll("script"))) el.remove();
|
||||||
|
|
||||||
|
// Rewrite internal wiki links: Quill stores slug as <a href="other-wiki-slug">...</a>
|
||||||
|
for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
|
||||||
|
const href = String(a.getAttribute("href") || "").trim();
|
||||||
|
if (!href.length) continue;
|
||||||
|
if (href === "__missing__") continue;
|
||||||
|
if (href.startsWith("#")) continue;
|
||||||
|
if (href.startsWith("/")) continue;
|
||||||
|
if (isExternalHref(href)) continue;
|
||||||
|
|
||||||
|
const match = href.match(/^([^?#]+)([?#].*)?$/);
|
||||||
|
const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim();
|
||||||
|
const suffix = String(match?.[2] || "");
|
||||||
|
|
||||||
|
const normalizedSlug = slugPart;
|
||||||
|
if (!normalizedSlug.length) continue;
|
||||||
|
|
||||||
|
a.setAttribute("href", `${wikiBaseUrl}${encodeURIComponent(normalizedSlug)}${suffix}`);
|
||||||
|
a.setAttribute("target", "_self");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build TOC from headings and ensure they have stable IDs.
|
||||||
|
const toc: TocItem[] = [];
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6"));
|
||||||
|
for (const h of headings) {
|
||||||
|
const text = String(h.textContent || "").trim();
|
||||||
|
if (!text.length) continue;
|
||||||
|
|
||||||
|
const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1;
|
||||||
|
const existingId = String(h.getAttribute("id") || "").trim();
|
||||||
|
if (existingId) {
|
||||||
|
toc.push({ id: existingId, level, text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = slugifyHeading(text) || "heading";
|
||||||
|
const n = (seen.get(base) || 0) + 1;
|
||||||
|
seen.set(base, n);
|
||||||
|
const id = n === 1 ? base : `${base}-${n}`;
|
||||||
|
|
||||||
|
h.setAttribute("id", id);
|
||||||
|
toc.push({ id, level, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { html: doc.body.innerHTML, toc };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null, options?: Intl.DateTimeFormatOptions): string {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return "-";
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (Number.isNaN(d.getTime())) return raw;
|
||||||
|
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 }) {
|
||||||
|
const [wiki, setWiki] = useState<Wiki | null>(null);
|
||||||
|
const [status, setStatus] = useState<"idle" | "loading" | "error" | "ready">("idle");
|
||||||
|
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 [toc, setToc] = useState<TocItem[]>([]);
|
||||||
|
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||||
|
const [linkPreview, setLinkPreview] = useState<{
|
||||||
|
slug: string;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
visible: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [linkPreviewData, setLinkPreviewData] = useState<{
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
quote: string | null;
|
||||||
|
status: "idle" | "loading" | "ready" | "error";
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const normalizedSlug = useMemo(() => String(slug || "").trim(), [slug]);
|
||||||
|
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const hidePreviewTimerRef = useRef<number | null>(null);
|
||||||
|
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.
|
||||||
|
useEffect(() => {
|
||||||
|
const value = String(normalizedSlug || "").trim();
|
||||||
|
if (!value.length) {
|
||||||
|
setWiki(null);
|
||||||
|
setStatus("error");
|
||||||
|
setError("Missing wiki slug.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
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 (!res) {
|
||||||
|
setWiki(null);
|
||||||
|
setStatus("ready");
|
||||||
|
setRenderHtml("");
|
||||||
|
setToc([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWiki({ ...res, content: versionContent });
|
||||||
|
setStatus("ready");
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed) return;
|
||||||
|
const msg =
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to load wiki.";
|
||||||
|
setStatus("error");
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [normalizedSlug]);
|
||||||
|
|
||||||
|
// Transform content: normalize -> rewrite internal links -> inject heading ids + toc.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wiki) {
|
||||||
|
setRenderHtml("");
|
||||||
|
setToc([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw =
|
||||||
|
(wiki.content ?? (wiki as unknown as { doc?: string | null }).doc ?? "") || "";
|
||||||
|
const html = normalizeWikiContentToHtml(raw);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = `${window.location.origin}/wiki/`;
|
||||||
|
const processed = rewriteHtmlAndBuildToc(html, base);
|
||||||
|
setRenderHtml(processed.html);
|
||||||
|
setToc(processed.toc);
|
||||||
|
setActiveHeadingId(processed.toc[0]?.id ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to process wiki HTML", err);
|
||||||
|
setRenderHtml(html);
|
||||||
|
setToc([]);
|
||||||
|
}
|
||||||
|
}, [wiki]);
|
||||||
|
|
||||||
|
// Track active heading for TOC highlight.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toc.length) return;
|
||||||
|
const root = contentRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const headings = toc
|
||||||
|
.map((t) => root.querySelector<HTMLElement>(`#${CSS.escape(t.id)}`))
|
||||||
|
.filter((el): el is HTMLElement => Boolean(el));
|
||||||
|
if (!headings.length) return;
|
||||||
|
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((e) => e.isIntersecting)
|
||||||
|
.sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
|
||||||
|
const top = visible[0]?.target as HTMLElement | undefined;
|
||||||
|
const id = top?.id || null;
|
||||||
|
if (id) setActiveHeadingId(id);
|
||||||
|
},
|
||||||
|
{ root: null, rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] }
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const h of headings) obs.observe(h);
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, [toc]);
|
||||||
|
|
||||||
|
// Hover preview for internal wiki links (title + first blockquote).
|
||||||
|
useEffect(() => {
|
||||||
|
const root = contentRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const clearHideTimer = () => {
|
||||||
|
if (hidePreviewTimerRef.current != null) {
|
||||||
|
window.clearTimeout(hidePreviewTimerRef.current);
|
||||||
|
hidePreviewTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideSoon = () => {
|
||||||
|
clearHideTimer();
|
||||||
|
hidePreviewTimerRef.current = window.setTimeout(() => {
|
||||||
|
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
|
||||||
|
}, 140);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveInternalWikiSlug = (href: string): string | null => {
|
||||||
|
const h = href.trim();
|
||||||
|
if (!h.length) return null;
|
||||||
|
if (h === "__missing__") return null;
|
||||||
|
if (h.startsWith("#")) return null;
|
||||||
|
|
||||||
|
const stripQueryHash = (s: string) => {
|
||||||
|
const m = s.match(/^([^?#]+)([?#].*)?$/);
|
||||||
|
return String(m?.[1] || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (h.startsWith("/wiki/")) {
|
||||||
|
const path = stripQueryHash(h);
|
||||||
|
const slugPart = path.slice("/wiki/".length).trim();
|
||||||
|
return slugPart ? decodeURIComponent(slugPart) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originPrefix = window.location.origin + "/wiki/";
|
||||||
|
if (h.startsWith(originPrefix)) {
|
||||||
|
const rest = stripQueryHash(h.slice(originPrefix.length));
|
||||||
|
const slugPart = rest.trim();
|
||||||
|
return slugPart ? decodeURIComponent(slugPart) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPreview = async (targetSlug: string) => {
|
||||||
|
const key = targetSlug.trim();
|
||||||
|
if (!key.length) return;
|
||||||
|
|
||||||
|
const cached = previewCacheRef.current.get(key);
|
||||||
|
if (cached) {
|
||||||
|
setLinkPreviewData({ slug: key, title: cached.title, quote: cached.quote, status: "ready" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinkPreviewData((prev) => ({ slug: key, title: prev?.title || key, quote: null, status: "loading" }));
|
||||||
|
try {
|
||||||
|
const row = await fetchWikiBySlug(key);
|
||||||
|
if (!row) {
|
||||||
|
setLinkPreviewData({ slug: key, title: key, quote: null, status: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = normalizeWikiContentToHtml(row.content ?? "");
|
||||||
|
let quote: string | null = null;
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, "text/html");
|
||||||
|
const bq = doc.body.querySelector("blockquote");
|
||||||
|
const txt = String(bq?.textContent || "").trim();
|
||||||
|
quote = txt.length ? txt : null;
|
||||||
|
} catch {
|
||||||
|
quote = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = String(row.title || "").trim() || key;
|
||||||
|
previewCacheRef.current.set(key, { title, quote });
|
||||||
|
setLinkPreviewData({ slug: key, title, quote, status: "ready" });
|
||||||
|
} catch {
|
||||||
|
setLinkPreviewData({ slug: key, title: key, quote: null, status: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showForAnchor = (a: HTMLAnchorElement) => {
|
||||||
|
const href = String(a.getAttribute("href") || "").trim();
|
||||||
|
const targetSlug = resolveInternalWikiSlug(href);
|
||||||
|
if (!targetSlug) return;
|
||||||
|
|
||||||
|
// Avoid previews on touch devices.
|
||||||
|
if (window.matchMedia && window.matchMedia("(hover: none)").matches) return;
|
||||||
|
|
||||||
|
const rect = a.getBoundingClientRect();
|
||||||
|
const width = 420;
|
||||||
|
const height = 320;
|
||||||
|
const margin = 12;
|
||||||
|
|
||||||
|
const preferredLeft = rect.right + margin;
|
||||||
|
const maxLeft = Math.max(margin, window.innerWidth - width - margin);
|
||||||
|
const left = Math.min(preferredLeft, maxLeft);
|
||||||
|
|
||||||
|
const preferredTop = rect.top;
|
||||||
|
const maxTop = Math.max(margin, window.innerHeight - height - margin);
|
||||||
|
const top = Math.max(margin, Math.min(preferredTop, maxTop));
|
||||||
|
|
||||||
|
clearHideTimer();
|
||||||
|
setLinkPreview({ slug: targetSlug, top, left, width, height, visible: true });
|
||||||
|
void fetchPreview(targetSlug);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseOver = (evt: MouseEvent) => {
|
||||||
|
const target = evt.target as HTMLElement | null;
|
||||||
|
const a = target?.closest?.("a") as HTMLAnchorElement | null;
|
||||||
|
if (!a) return;
|
||||||
|
showForAnchor(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseOut = (evt: MouseEvent) => {
|
||||||
|
const target = evt.target as HTMLElement | null;
|
||||||
|
const related = evt.relatedTarget as HTMLElement | null;
|
||||||
|
const fromA = target?.closest?.("a");
|
||||||
|
if (!fromA) return;
|
||||||
|
if (related && related.closest?.(".uhm-wiki-link-preview")) return;
|
||||||
|
hideSoon();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (evt: KeyboardEvent) => {
|
||||||
|
if (evt.key === "Escape") {
|
||||||
|
clearHideTimer();
|
||||||
|
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener("mouseover", onMouseOver);
|
||||||
|
root.addEventListener("mouseout", onMouseOut);
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener("mouseover", onMouseOver);
|
||||||
|
root.removeEventListener("mouseout", onMouseOut);
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
clearHideTimer();
|
||||||
|
};
|
||||||
|
}, [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 (
|
||||||
|
<div className="min-h-screen bg-[#f8f9fa] text-[#202122] font-sans">
|
||||||
|
<header className="bg-white border-b border-gray-300 px-6 py-2 flex justify-between items-center">
|
||||||
|
<div className="text-lg font-bold">GeoHistory Wiki</div>
|
||||||
|
<Link href="/" className="text-sm text-blue-600 hover:underline">Trang chủ</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{viewMode === 'compare' && (
|
||||||
|
<div className="mt-4 p-3 border border-gray-300 bg-white rounded-sm text-xs space-y-1">
|
||||||
|
<div><span className="font-semibold">Slug:</span> {normalizedSlug || "-"}</div>
|
||||||
|
<div><span className="font-semibold">ID:</span> {wiki.id || "-"}</div>
|
||||||
|
<div><span className="font-semibold">Dự án:</span> {wiki.project_id || "-"}</div>
|
||||||
|
<div><span className="font-semibold">Tạo lúc:</span> {formatDate(wiki.created_at)}</div>
|
||||||
|
<div><span className="font-semibold">Cập nhật:</span> {formatDate(wiki.updated_at)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`grid grid-cols-1 ${viewMode === 'compare' ? '' : 'lg:grid-cols-[minmax(0,1fr)_auto] gap-8 items-start'}`}>
|
||||||
|
<main className={`min-w-0 bg-white ${viewMode === 'compare' ? 'border-y border-gray-300' : 'border border-gray-300 rounded-sm'}`}>
|
||||||
|
<div className={`flex border-b border-gray-300 text-sm ${viewMode === 'compare' ? 'mx-auto max-w-7xl px-4 sm:px-6' : ''}`}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'read' && (
|
||||||
|
<div ref={contentRootRef} className="uhm-wiki-view ql-editor wiki-article" dangerouslySetInnerHTML={{ __html: renderHtml }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 className="border rounded-md overflow-hidden">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'compare' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||||
|
<h2 className="text-xl mb-4 font-normal">So sánh các phiên bản</h2>
|
||||||
|
</div>
|
||||||
|
<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`}>
|
||||||
|
{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) => {
|
||||||
|
const pad = Math.max(0, Math.min(5, t.level - 1)) * 12;
|
||||||
|
const isActive = activeHeadingId === t.id;
|
||||||
|
return (
|
||||||
|
<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}>
|
||||||
|
<span className="mr-1">{t.level}.</span>{t.text}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border border-gray-300 bg-white rounded-sm text-xs overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
<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>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkPreview && linkPreview.visible ? (
|
||||||
|
<div
|
||||||
|
className="uhm-wiki-link-preview fixed z-[9999]"
|
||||||
|
style={{
|
||||||
|
top: linkPreview.top,
|
||||||
|
left: linkPreview.left,
|
||||||
|
width: linkPreview.width,
|
||||||
|
height: linkPreview.height,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (hidePreviewTimerRef.current != null) {
|
||||||
|
window.clearTimeout(hidePreviewTimerRef.current);
|
||||||
|
hidePreviewTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setLinkPreview((prev) => (prev ? { ...prev, visible: false } : prev));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-lg">
|
||||||
|
<div className="h-full w-full p-3 grid grid-rows-[auto_1fr] gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] text-gray-500 dark:text-gray-400 break-all">
|
||||||
|
/wiki/{linkPreview.slug}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{linkPreviewData?.slug === linkPreview.slug
|
||||||
|
? linkPreviewData.status === "loading"
|
||||||
|
? "Loading..."
|
||||||
|
: linkPreviewData.status === "error"
|
||||||
|
? "Not found"
|
||||||
|
: linkPreviewData.title
|
||||||
|
: "Loading..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 overflow-auto">
|
||||||
|
{linkPreviewData?.slug === linkPreview.slug && linkPreviewData.status === "ready" ? (
|
||||||
|
linkPreviewData.quote ? (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-words">
|
||||||
|
{linkPreviewData.quote}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">No resume.</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Loading preview...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
.wiki-article {
|
||||||
|
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.uhm-wiki-view.ql-editor {
|
||||||
|
height: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
.wiki-article p {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
}
|
||||||
|
.wiki-article h1,
|
||||||
|
.wiki-article h2,
|
||||||
|
.wiki-article h3,
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.wiki-article h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 1.4em;
|
||||||
|
}
|
||||||
|
.wiki-article h3 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.wiki-article h4,
|
||||||
|
.wiki-article h5,
|
||||||
|
.wiki-article h6 {
|
||||||
|
font-size: 1.05em;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.wiki-article ul,
|
||||||
|
.wiki-article ol {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
.wiki-article blockquote {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 3px solid #a2a9b1;
|
||||||
|
color: #202122;
|
||||||
|
}
|
||||||
|
.wiki-article pre {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #a2a9b1;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.wiki-article img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.wiki-article a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.wiki-article a[href]:not([href=""]):not([href="__missing__"]) {
|
||||||
|
color: #3366cc;
|
||||||
|
}
|
||||||
|
.wiki-article a[href]:not([href=""]):not([href="__missing__"]):hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.wiki-article a[href="__missing__"] {
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.wiki-article a:not([href]),
|
||||||
|
.wiki-article a[href=""],
|
||||||
|
.wiki-article a[href="__missing__"] {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "@fullcalendar/core";
|
} from "@fullcalendar/core";
|
||||||
import { useModal } from "@/hooks/useModal";
|
import { useModal } from "@/hooks/useModal";
|
||||||
import { Modal } from "@/components/ui/modal";
|
import { Modal } from "@/components/ui/modal";
|
||||||
import { newId } from "@/uhm/lib/id";
|
import { newId } from "@/uhm/lib/utils/id";
|
||||||
|
|
||||||
interface CalendarEvent extends EventInput {
|
interface CalendarEvent extends EventInput {
|
||||||
extendedProps: {
|
extendedProps: {
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
interface ButtonProps {
|
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
children: ReactNode; // Button text or content
|
|
||||||
size?: "sm" | "md"; // Button size
|
size?: "sm" | "md"; // Button size
|
||||||
variant?: "primary" | "outline"; // Button variant
|
variant?: "primary" | "outline"; // Button variant
|
||||||
startIcon?: ReactNode; // Icon before the text
|
startIcon?: ReactNode; // Icon before the text
|
||||||
endIcon?: ReactNode; // Icon after the text
|
endIcon?: ReactNode; // Icon after the text
|
||||||
onClick?: () => void; // Click handler
|
};
|
||||||
disabled?: boolean; // Disabled state
|
|
||||||
className?: string; // Disabled state
|
|
||||||
type?: "button" | "submit" | "reset";
|
|
||||||
}
|
|
||||||
|
|
||||||
const Button: React.FC<ButtonProps> = ({
|
const Button: React.FC<ButtonProps> = ({
|
||||||
children,
|
children,
|
||||||
@@ -18,10 +13,10 @@ const Button: React.FC<ButtonProps> = ({
|
|||||||
variant = "primary",
|
variant = "primary",
|
||||||
startIcon,
|
startIcon,
|
||||||
endIcon,
|
endIcon,
|
||||||
onClick,
|
|
||||||
className = "",
|
className = "",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
type = "button",
|
type = "button",
|
||||||
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
// Size Classes
|
// Size Classes
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
@@ -44,8 +39,9 @@ const Button: React.FC<ButtonProps> = ({
|
|||||||
} ${variantClasses[variant]} ${
|
} ${variantClasses[variant]} ${
|
||||||
disabled ? "cursor-not-allowed opacity-50" : ""
|
disabled ? "cursor-not-allowed opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
type={type}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
{startIcon && <span className="flex items-center">{startIcon}</span>}
|
{startIcon && <span className="flex items-center">{startIcon}</span>}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { API } from "../../api";
|
|
||||||
|
|
||||||
const axiosInstance = axios.create({
|
|
||||||
baseURL: "/",
|
|
||||||
withCredentials: true,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
axiosInstance.interceptors.response.use(
|
|
||||||
(response) => {
|
|
||||||
if (response.data && response.data.status === false) {
|
|
||||||
return handleRefreshToken(response);
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
async (error) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
async function handleRefreshToken(originalResponse: any) {
|
|
||||||
try {
|
|
||||||
const refreshRes = await axios.get(API.Auth.REFRESH, { withCredentials: true });
|
|
||||||
|
|
||||||
if (refreshRes.data && refreshRes.data.status !== false) {
|
|
||||||
return axiosInstance(originalResponse.config);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Refresh token failed", err);
|
|
||||||
}
|
|
||||||
return originalResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default axiosInstance;
|
|
||||||
+66
-45
@@ -1,10 +1,9 @@
|
|||||||
import axios from "axios"
|
import axios, { AxiosResponse } from "axios"
|
||||||
import { API_URL_ROOT } from "../../api"
|
import { API_URL_ROOT } from "../../api"
|
||||||
import {
|
import {
|
||||||
clearStoredTokens,
|
clearStoredTokens,
|
||||||
extractTokensFromResponsePayload,
|
extractTokensFromResponsePayload,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
getRefreshToken,
|
|
||||||
setStoredTokens,
|
setStoredTokens,
|
||||||
} from "@/auth/tokenStore"
|
} from "@/auth/tokenStore"
|
||||||
|
|
||||||
@@ -16,6 +15,12 @@ const api = axios.create({
|
|||||||
withCredentials: true
|
withCredentials: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Dedicated instance for refresh to avoid interceptor loops and handle baseURL correctly.
|
||||||
|
const refreshApi = axios.create({
|
||||||
|
baseURL,
|
||||||
|
withCredentials: true
|
||||||
|
})
|
||||||
|
|
||||||
let isRefreshing = false
|
let isRefreshing = false
|
||||||
let queue: any[] = []
|
let queue: any[] = []
|
||||||
|
|
||||||
@@ -27,16 +32,17 @@ const processQueue = (error?: any) => {
|
|||||||
queue = []
|
queue = []
|
||||||
}
|
}
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config: any) => {
|
||||||
const token = getAccessToken()
|
if (config.skipAuth) return config
|
||||||
|
|
||||||
|
const token = config.authToken || getAccessToken()
|
||||||
if (token) {
|
if (token) {
|
||||||
const headers: any = config.headers || {}
|
const headers: any = config.headers || {}
|
||||||
// Do not override if caller set Authorization explicitly (case-insensitive).
|
// If it's a retry after refresh, we MUST update the Authorization header with the fresh token.
|
||||||
const already =
|
// Otherwise, we only set it if not already present.
|
||||||
typeof headers.get === "function"
|
const hasAuth = !!(headers.Authorization || headers.authorization || (typeof headers.get === "function" && headers.get("Authorization")))
|
||||||
? headers.get("Authorization")
|
|
||||||
: headers.Authorization || headers.authorization
|
if (config._retry || !hasAuth) {
|
||||||
if (!already) {
|
|
||||||
if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`)
|
if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`)
|
||||||
else headers.Authorization = `Bearer ${token}`
|
else headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
@@ -45,18 +51,59 @@ api.interceptors.request.use((config) => {
|
|||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function isAuthTokenExpiredMessage(message: string): boolean {
|
||||||
|
const normalized = message.trim().toLowerCase()
|
||||||
|
if (!normalized) return false
|
||||||
|
// Be specific: don't match general "unauthorized" or "access denied" which could be 403.
|
||||||
|
// Match only messages clearly indicating token expiration or invalidity.
|
||||||
|
return (
|
||||||
|
normalized.includes("invalid or expired jwt") ||
|
||||||
|
normalized.includes("jwt expired") ||
|
||||||
|
normalized.includes("token expired") ||
|
||||||
|
normalized.includes("invalid token") ||
|
||||||
|
normalized.includes("expired token") ||
|
||||||
|
normalized.includes("token is invalid") ||
|
||||||
|
normalized.includes("not authenticated")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(res) => {
|
async (res: AxiosResponse): Promise<AxiosResponse> => {
|
||||||
// Opportunistically persist tokens from signin/refresh responses.
|
// Opportunistically persist tokens from signin/refresh responses.
|
||||||
const tokens = extractTokensFromResponsePayload(res?.data)
|
const tokens = extractTokensFromResponsePayload(res?.data)
|
||||||
if (tokens) setStoredTokens(tokens)
|
if (tokens) setStoredTokens(tokens)
|
||||||
|
|
||||||
|
// Handle backends that return 200 OK with status:false + expired token message.
|
||||||
|
const data = res.data
|
||||||
|
const originalRequest = res.config as any
|
||||||
|
const url = String(originalRequest?.url || "")
|
||||||
|
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
data.status === false &&
|
||||||
|
isAuthTokenExpiredMessage(data.message || "") &&
|
||||||
|
!originalRequest._retry &&
|
||||||
|
!originalRequest.skipRefresh &&
|
||||||
|
!url.includes("/auth/")
|
||||||
|
) {
|
||||||
|
return performRefreshAndRetry(originalRequest)
|
||||||
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
},
|
},
|
||||||
async (err) => {
|
async (err) => {
|
||||||
const originalRequest = err.config
|
const originalRequest = err.config as any
|
||||||
|
|
||||||
const url = String(originalRequest?.url || "")
|
const url = String(originalRequest?.url || "")
|
||||||
if (err.response?.status === 401 && !originalRequest._retry && !url.includes("/auth/")) {
|
if (err.response?.status === 401 && !originalRequest._retry && !originalRequest.skipRefresh && !url.includes("/auth/")) {
|
||||||
|
return performRefreshAndRetry(originalRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async function performRefreshAndRetry(originalRequest: any): Promise<AxiosResponse> {
|
||||||
if (isRefreshing) {
|
if (isRefreshing) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
queue.push({
|
queue.push({
|
||||||
@@ -70,64 +117,38 @@ api.interceptors.response.use(
|
|||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const refreshToken = getRefreshToken()
|
|
||||||
|
|
||||||
const tryHeaderRefresh = async () => {
|
|
||||||
if (!refreshToken) return null
|
|
||||||
return axios.post(
|
|
||||||
`${baseURL}/auth/refresh`,
|
|
||||||
{},
|
|
||||||
{ headers: { Authorization: `Bearer ${refreshToken}` } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tryCookieRefresh = async () => {
|
const tryCookieRefresh = async () => {
|
||||||
return axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true })
|
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)
|
||||||
// Some backends may return only a new access token; keep refresh token.
|
// Some backends may return only a new access token; keep refresh token.
|
||||||
else {
|
else {
|
||||||
const maybeAccess = (refreshRes?.data?.data?.access_token ??
|
const maybeAccess = (refreshRes?.data?.data?.access_token ?? refreshRes?.data?.access_token) as unknown
|
||||||
refreshRes?.data?.access_token) as unknown
|
|
||||||
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
||||||
// Keep refresh token if we have one; otherwise rely on cookies.
|
|
||||||
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken })
|
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processQueue()
|
processQueue()
|
||||||
|
|
||||||
return api(originalRequest)
|
return api(originalRequest)
|
||||||
} 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") {
|
||||||
window.location.href = "/signin"
|
window.location.href = "/signin"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return Promise.reject(refreshErr)
|
return Promise.reject(refreshErr)
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(err)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -156,8 +156,7 @@ const AppHeader: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${isApplicationMenuOpen ? "flex" : "hidden"
|
||||||
isApplicationMenuOpen ? "flex" : "hidden"
|
|
||||||
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
|
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 2xsm:gap-3">
|
<div className="flex items-center gap-2 2xsm:gap-3">
|
||||||
|
|||||||
@@ -164,8 +164,7 @@ const AppSidebar: React.FC = () => {
|
|||||||
{nav.subItems ? (
|
{nav.subItems ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSubmenuToggle(index, menuType)}
|
onClick={() => handleSubmenuToggle(index, menuType)}
|
||||||
className={`menu-item group uppercase ${
|
className={`menu-item group uppercase ${openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
|
||||||
? "menu-item-active"
|
? "menu-item-active"
|
||||||
: "menu-item-inactive"
|
: "menu-item-inactive"
|
||||||
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
|
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
|
||||||
|
|||||||
+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`,
|
||||||
|
|||||||
+17
-3
@@ -4,11 +4,25 @@ import type { Entity } from "@/uhm/types/entities";
|
|||||||
|
|
||||||
export type { Entity } from "@/uhm/types/entities";
|
export type { Entity } from "@/uhm/types/entities";
|
||||||
|
|
||||||
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
export async function fetchEntities(query?: {
|
||||||
|
q?: string;
|
||||||
|
limit?: number;
|
||||||
|
cursor?: string;
|
||||||
|
projectId?: string;
|
||||||
|
}): Promise<Entity[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
// API mới dùng `name` thay vì `q`.
|
// API mới dùng `name` thay vì `q`.
|
||||||
if (query?.q) {
|
if (query && "q" in query) {
|
||||||
params.set("name", query.q);
|
params.set("name", String(query.q ?? ""));
|
||||||
|
}
|
||||||
|
if (query?.limit && Number.isFinite(query.limit)) {
|
||||||
|
params.set("limit", String(Math.trunc(query.limit)));
|
||||||
|
}
|
||||||
|
if (query?.cursor) {
|
||||||
|
params.set("cursor", query.cursor);
|
||||||
|
}
|
||||||
|
if (query?.projectId) {
|
||||||
|
params.set("project_id", query.projectId);
|
||||||
}
|
}
|
||||||
const suffix = params.toString();
|
const suffix = params.toString();
|
||||||
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
|
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { API_ENDPOINTS } from "@/uhm/api/config";
|
|||||||
import { requestJson } from "@/uhm/api/http";
|
import { requestJson } from "@/uhm/api/http";
|
||||||
import type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
import type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||||
import type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||||
import { geoTypeCodeToTypeKey } from "@/uhm/lib/geoTypeMap";
|
import { geoTypeCodeToTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||||
|
|
||||||
export type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
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
|
||||||
@@ -40,6 +52,10 @@ function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
|||||||
query.set("time", String(params.time));
|
query.set("time", String(params.time));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.timeRange !== undefined) {
|
||||||
|
query.set("time_range", String(params.timeRange));
|
||||||
|
}
|
||||||
|
|
||||||
if (params.entity_id) {
|
if (params.entity_id) {
|
||||||
query.set("entity_id", params.entity_id);
|
query.set("entity_id", params.entity_id);
|
||||||
}
|
}
|
||||||
@@ -67,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 = {
|
||||||
|
|||||||
+51
-150
@@ -1,6 +1,5 @@
|
|||||||
import type { ApiEnvelope } from "@/uhm/types/api";
|
import type { ApiEnvelope } from "@/uhm/types/api";
|
||||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
import api from "@/config/config";
|
||||||
import { getAccessToken, getRefreshToken, setStoredTokens, type StoredTokens, extractTokensFromResponsePayload } from "@/auth/tokenStore";
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -16,8 +15,6 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// History API auth flow supports Bearer JWT and (in some deployments) cookie-based sessions.
|
|
||||||
|
|
||||||
type RequestJsonOptions = {
|
type RequestJsonOptions = {
|
||||||
skipAuth?: boolean;
|
skipAuth?: boolean;
|
||||||
skipRefresh?: boolean;
|
skipRefresh?: boolean;
|
||||||
@@ -29,7 +26,56 @@ export async function requestJson<T>(
|
|||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
options?: RequestJsonOptions
|
options?: RequestJsonOptions
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return requestJsonInternal<T>(input, init, options);
|
const url = typeof input === "string" ? input : String(input);
|
||||||
|
const method = init?.method || "GET";
|
||||||
|
|
||||||
|
// Convert RequestInit.body to object if it's a JSON string.
|
||||||
|
let data = init?.body;
|
||||||
|
if (typeof data === "string" && data.length > 0) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
// Keep as string if not JSON.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.request({
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
data,
|
||||||
|
headers: init?.headers as any,
|
||||||
|
// Custom properties for our axios interceptor.
|
||||||
|
skipAuth: options?.skipAuth,
|
||||||
|
authToken: options?.authToken,
|
||||||
|
skipRefresh: options?.skipRefresh,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const payload = response.data;
|
||||||
|
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
|
||||||
|
|
||||||
|
if (envelope) {
|
||||||
|
const isError = envelope.status === false || envelope.status === "error";
|
||||||
|
if (isError) {
|
||||||
|
const message = extractErrorMessage(payload, envelope) || "Request failed";
|
||||||
|
throw new ApiError(message, response.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
|
||||||
|
}
|
||||||
|
return (envelope.data ?? null) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
|
||||||
|
const status = err.response?.status || 0;
|
||||||
|
const payload = err.response?.data;
|
||||||
|
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
|
||||||
|
const message = extractErrorMessage(payload, envelope) || err.message || "Request failed";
|
||||||
|
const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload);
|
||||||
|
const errors = envelope?.errors ? normalizeErrors(envelope.errors) : [];
|
||||||
|
|
||||||
|
throw new ApiError(message, status, body, errors);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jsonRequestInit(method: string, body: unknown): RequestInit {
|
export function jsonRequestInit(method: string, body: unknown): RequestInit {
|
||||||
@@ -40,74 +86,6 @@ export function jsonRequestInit(method: string, body: unknown): RequestInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJsonInternal<T>(
|
|
||||||
input: RequestInfo | URL,
|
|
||||||
init?: RequestInit,
|
|
||||||
options?: RequestJsonOptions
|
|
||||||
): Promise<T> {
|
|
||||||
const nextInit = withAuthHeaders(init, options);
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(input, nextInit);
|
|
||||||
} catch (err) {
|
|
||||||
// Browser "TypeError: Failed to fetch" typically means:
|
|
||||||
// - CORS blocked (common when using 127.0.0.1 instead of localhost in dev),
|
|
||||||
// - DNS/TLS/network error,
|
|
||||||
// - request blocked by the browser.
|
|
||||||
const origin = typeof window !== "undefined" ? window.location.origin : "<server>";
|
|
||||||
const url = typeof input === "string" ? input : String(input);
|
|
||||||
const details = { origin, url, apiBase: API_ENDPOINTS.projects.split("/projects")[0] };
|
|
||||||
throw new ApiError("Network error (failed to fetch)", 0, stringifyPayload(details));
|
|
||||||
}
|
|
||||||
|
|
||||||
// One-shot refresh + retry for protected endpoints.
|
|
||||||
if (
|
|
||||||
res.status === 401 &&
|
|
||||||
!options?.skipRefresh &&
|
|
||||||
!options?.skipAuth &&
|
|
||||||
typeof input === "string" &&
|
|
||||||
!String(input).includes("/auth/")
|
|
||||||
) {
|
|
||||||
const refreshed = await tryRefreshTokens();
|
|
||||||
if (refreshed) {
|
|
||||||
return requestJsonInternal<T>(input, init, { ...(options || {}), skipRefresh: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await parseJsonResponse(res);
|
|
||||||
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const message = extractErrorMessage(payload, envelope) || `Request failed with status ${res.status}`;
|
|
||||||
const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload);
|
|
||||||
const errors = envelope?.errors ? normalizeErrors(envelope.errors) : [];
|
|
||||||
throw new ApiError(message, res.status, body, errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (envelope) {
|
|
||||||
const isError =
|
|
||||||
envelope.status === false ||
|
|
||||||
envelope.status === "error";
|
|
||||||
if (isError) {
|
|
||||||
const message = extractErrorMessage(payload, envelope) || "Request failed";
|
|
||||||
throw new ApiError(message, res.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
|
|
||||||
}
|
|
||||||
return (envelope.data ?? null) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseJsonResponse(res: Response): Promise<unknown> {
|
|
||||||
const text = await res.text();
|
|
||||||
if (!text.length) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isApiEnvelopeLike<T>(value: unknown): value is ApiEnvelope<T> {
|
function isApiEnvelopeLike<T>(value: unknown): value is ApiEnvelope<T> {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
const source = value as Record<string, unknown>;
|
const source = value as Record<string, unknown>;
|
||||||
@@ -139,80 +117,3 @@ function stringifyPayload(payload: unknown): string {
|
|||||||
return String(payload);
|
return String(payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined {
|
|
||||||
const baseInit: RequestInit = {
|
|
||||||
...init,
|
|
||||||
credentials: init?.credentials ?? "include",
|
|
||||||
};
|
|
||||||
|
|
||||||
const headers = new Headers(baseInit.headers || undefined);
|
|
||||||
|
|
||||||
const override = options?.authToken;
|
|
||||||
if (override) {
|
|
||||||
headers.set("Authorization", `Bearer ${override}`);
|
|
||||||
return { ...baseInit, headers };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.skipAuth) return baseInit;
|
|
||||||
|
|
||||||
const access = getAccessToken();
|
|
||||||
if (access) headers.set("Authorization", `Bearer ${access}`);
|
|
||||||
return { ...baseInit, headers };
|
|
||||||
}
|
|
||||||
|
|
||||||
let refreshInFlight: Promise<boolean> | null = null;
|
|
||||||
|
|
||||||
async function tryRefreshTokens(): Promise<boolean> {
|
|
||||||
// Single-flight refresh for concurrent 401s.
|
|
||||||
if (refreshInFlight) return refreshInFlight;
|
|
||||||
refreshInFlight = (async () => {
|
|
||||||
try {
|
|
||||||
const refreshToken = getRefreshToken();
|
|
||||||
|
|
||||||
// Try header-based refresh first (per swagger), but fall back to cookie-based refresh if needed.
|
|
||||||
let payload: unknown;
|
|
||||||
try {
|
|
||||||
payload = await requestJsonInternal<unknown>(
|
|
||||||
API_ENDPOINTS.authRefresh,
|
|
||||||
{ method: "POST" },
|
|
||||||
refreshToken
|
|
||||||
? { skipRefresh: true, authToken: refreshToken }
|
|
||||||
: { skipRefresh: true, skipAuth: true }
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (refreshToken && err instanceof ApiError && err.status === 401) {
|
|
||||||
payload = await requestJsonInternal<unknown>(
|
|
||||||
API_ENDPOINTS.authRefresh,
|
|
||||||
{ method: "POST" },
|
|
||||||
{ skipRefresh: true, skipAuth: true }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = extractTokensFromResponsePayload(payload) as StoredTokens | null;
|
|
||||||
if (next) {
|
|
||||||
setStoredTokens(next);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: if server returns only access_token, keep existing refresh token (if any).
|
|
||||||
const maybeAccess = (payload as any)?.access_token ?? (payload as any)?.data?.access_token;
|
|
||||||
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
|
||||||
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
try {
|
|
||||||
return await refreshInFlight;
|
|
||||||
} finally {
|
|
||||||
refreshInFlight = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,44 +1,45 @@
|
|||||||
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,
|
||||||
CreateSectionInput,
|
CreateProjectInput,
|
||||||
EditorLoadResponse,
|
EditorLoadResponse,
|
||||||
RestoreCommitInput,
|
RestoreCommitInput,
|
||||||
Section,
|
Project,
|
||||||
SectionCommit,
|
ProjectCommit,
|
||||||
SectionState,
|
ProjectState,
|
||||||
SectionSubmission,
|
ProjectSubmission,
|
||||||
} from "@/uhm/types/sections";
|
} from "@/uhm/types/projects";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CreateCommitInput,
|
CreateCommitInput,
|
||||||
CreateSectionInput,
|
CreateProjectInput,
|
||||||
EditorLoadResponse,
|
EditorLoadResponse,
|
||||||
RestoreCommitInput,
|
RestoreCommitInput,
|
||||||
Section,
|
Project,
|
||||||
SectionCommit,
|
ProjectCommit,
|
||||||
SectionState,
|
ProjectState,
|
||||||
SectionSubmission,
|
ProjectSubmission,
|
||||||
} from "@/uhm/types/sections";
|
} from "@/uhm/types/projects";
|
||||||
|
|
||||||
// Sections (API cũ) => Projects (API mới)
|
// Projects (API cũ) => Projects (API mới)
|
||||||
|
|
||||||
export async function fetchSections(): Promise<Section[]> {
|
export async function fetchProjects(): Promise<Project[]> {
|
||||||
// /users/current/project requires JWT.
|
// /users/current/project requires JWT.
|
||||||
return requestJson<Section[]>(API_ENDPOINTS.currentUserProjects);
|
return requestJson<Project[]>(API_ENDPOINTS.currentUserProjects);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSection(input: CreateSectionInput): Promise<Section> {
|
export async function createProject(input: CreateProjectInput): Promise<Project> {
|
||||||
// POST /projects
|
// POST /projects
|
||||||
return requestJson<Section>(API_ENDPOINTS.projects, jsonRequestInit("POST", input));
|
return requestJson<Project>(API_ENDPOINTS.projects, jsonRequestInit("POST", input));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSectionEditor(sectionId: string): Promise<EditorLoadResponse> {
|
export async function openSectionEditor(projectId: string): Promise<EditorLoadResponse> {
|
||||||
// API mới không có endpoint "editor". FE tự load:
|
// API mới không có endpoint "editor". FE tự load:
|
||||||
// 1) Project details
|
// 1) Project details
|
||||||
// 2) Project commits (to get snapshot_json of latest commit)
|
// 2) Project commits (to get snapshot_json of latest commit)
|
||||||
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
|
||||||
|
|
||||||
const pending = (project.submissions || []).find((s) => s?.status === "PENDING") || null;
|
const pending = (project.submissions || []).find((s) => s?.status === "PENDING") || null;
|
||||||
if (pending) {
|
if (pending) {
|
||||||
@@ -51,42 +52,43 @@ export async function openSectionEditor(sectionId: string): Promise<EditorLoadRe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const commits = await fetchSectionCommits(sectionId);
|
const commits = await fetchProjectCommits(projectId);
|
||||||
|
|
||||||
const headCommitId = project.latest_commit_id ?? null;
|
const headCommitId = project.latest_commit_id ?? null;
|
||||||
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
||||||
const snapshot = headCommit?.snapshot_json ?? null;
|
const snapshot = headCommit?.snapshot_json ?? null;
|
||||||
|
|
||||||
const state: SectionState = {
|
const state: ProjectState = {
|
||||||
status: project.project_status || "ACTIVE",
|
status: project.project_status || "ACTIVE",
|
||||||
head_commit_id: headCommitId,
|
head_commit_id: headCommitId,
|
||||||
locked_by: project.locked_by ?? null,
|
locked_by: project.locked_by ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
section: project,
|
project: project,
|
||||||
state,
|
state,
|
||||||
commit: headCommit,
|
commit: headCommit,
|
||||||
snapshot,
|
snapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSectionCommit(
|
export async function createProjectCommit(
|
||||||
sectionId: string,
|
projectId: string,
|
||||||
input: CreateCommitInput
|
input: CreateCommitInput
|
||||||
): Promise<{ commit: SectionCommit; state: SectionState }> {
|
): Promise<{ commit: ProjectCommit; state: ProjectState }> {
|
||||||
// POST /projects/{id}/commits
|
// POST /projects/{id}/commits
|
||||||
const commit = await requestJson<SectionCommit>(
|
const snapshot = toApiEditorSnapshot(input.snapshot);
|
||||||
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`,
|
const commit = await requestJson<ProjectCommit>(
|
||||||
|
`${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,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refresh project state (latest_commit_id may have moved).
|
// Refresh project state (latest_commit_id may have moved).
|
||||||
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
|
||||||
const state: SectionState = {
|
const state: ProjectState = {
|
||||||
status: project.project_status || "ACTIVE",
|
status: project.project_status || "ACTIVE",
|
||||||
head_commit_id: project.latest_commit_id ?? null,
|
head_commit_id: project.latest_commit_id ?? null,
|
||||||
locked_by: project.locked_by ?? null,
|
locked_by: project.locked_by ?? null,
|
||||||
@@ -95,27 +97,27 @@ export async function createSectionCommit(
|
|||||||
return { commit, state };
|
return { commit, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSectionCommits(sectionId: string): Promise<SectionCommit[]> {
|
export async function fetchProjectCommits(projectId: string): Promise<ProjectCommit[]> {
|
||||||
return requestJson<SectionCommit[]>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`);
|
return requestJson<ProjectCommit[]>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreSectionCommit(
|
export async function restoreProjectCommit(
|
||||||
sectionId: string,
|
projectId: string,
|
||||||
input: RestoreCommitInput
|
input: RestoreCommitInput
|
||||||
): Promise<{ commit: SectionCommit | null; state: SectionState }> {
|
): Promise<{ commit: ProjectCommit | null; state: ProjectState }> {
|
||||||
// POST /projects/{id}/commits/restore
|
// POST /projects/{id}/commits/restore
|
||||||
await requestJson(
|
await requestJson(
|
||||||
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits/restore`,
|
`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}/commits/restore`,
|
||||||
jsonRequestInit("POST", { commit_id: input.commit_id })
|
jsonRequestInit("POST", { commit_id: input.commit_id })
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reload commits + project to determine new head commit.
|
// Reload commits + project to determine new head commit.
|
||||||
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
|
||||||
const commits = await fetchSectionCommits(sectionId);
|
const commits = await fetchProjectCommits(projectId);
|
||||||
const headCommitId = project.latest_commit_id ?? null;
|
const headCommitId = project.latest_commit_id ?? null;
|
||||||
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
||||||
|
|
||||||
const state: SectionState = {
|
const state: ProjectState = {
|
||||||
status: project.project_status || "ACTIVE",
|
status: project.project_status || "ACTIVE",
|
||||||
head_commit_id: headCommitId,
|
head_commit_id: headCommitId,
|
||||||
locked_by: project.locked_by ?? null,
|
locked_by: project.locked_by ?? null,
|
||||||
@@ -124,20 +126,20 @@ export async function restoreSectionCommit(
|
|||||||
return { commit: headCommit, state };
|
return { commit: headCommit, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitSection(sectionId: string): Promise<SectionSubmission> {
|
export async function submitSection(projectId: string, content: string): Promise<ProjectSubmission> {
|
||||||
// Submit latest commit of project
|
// Submit latest commit of project
|
||||||
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
const project = await requestJson<Project>(`${API_ENDPOINTS.projects}/${encodeURIComponent(projectId)}`);
|
||||||
const commitId = project.latest_commit_id;
|
const commitId = project.latest_commit_id;
|
||||||
if (!commitId) {
|
if (!commitId) {
|
||||||
throw new Error("Project has no latest commit to submit");
|
throw new Error("Project has no latest commit to submit");
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestJson<SectionSubmission>(
|
return requestJson<ProjectSubmission>(
|
||||||
API_ENDPOINTS.submissions,
|
API_ENDPOINTS.submissions,
|
||||||
jsonRequestInit("POST", {
|
jsonRequestInit("POST", {
|
||||||
project_id: sectionId,
|
project_id: projectId,
|
||||||
commit_id: commitId,
|
commit_id: commitId,
|
||||||
content: "",
|
content: content,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+31
-8
@@ -1,19 +1,25 @@
|
|||||||
|
import api from "@/config/config";
|
||||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
import { requestJson } from "@/uhm/api/http";
|
import { ApiError, requestJson } from "@/uhm/api/http";
|
||||||
|
|
||||||
export type Wiki = {
|
export type Wiki = {
|
||||||
id: string;
|
id: string;
|
||||||
|
project_id?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
slug?: string | null;
|
||||||
content?: string;
|
content?: string;
|
||||||
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[]> {
|
||||||
const keyword = title.trim();
|
const keyword = title.trim();
|
||||||
if (!keyword.length) return [];
|
|
||||||
|
|
||||||
const params = new URLSearchParams({ title: keyword });
|
const params = new URLSearchParams({ title: keyword });
|
||||||
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
|
if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit)));
|
||||||
if (options?.cursor) params.set("cursor", options.cursor);
|
if (options?.cursor) params.set("cursor", options.cursor);
|
||||||
@@ -28,6 +34,18 @@ export async function fetchWikiById(id: string): Promise<Wiki> {
|
|||||||
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchWikiBySlug(slug: string): Promise<Wiki | null> {
|
||||||
|
const value = String(slug || "").trim();
|
||||||
|
if (!value.length) return null;
|
||||||
|
try {
|
||||||
|
return await requestJson<Wiki>(`${API_ENDPOINTS.wikis}/slug/${encodeURIComponent(value)}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Treat "not found" as an empty result for search UX.
|
||||||
|
if (err instanceof ApiError && err.status === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
||||||
const value = String(slug || "").trim();
|
const value = String(slug || "").trim();
|
||||||
if (!value.length) return false;
|
if (!value.length) return false;
|
||||||
@@ -38,13 +56,18 @@ export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
|||||||
|
|
||||||
if (typeof payload === "boolean") return payload;
|
if (typeof payload === "boolean") return payload;
|
||||||
if (payload && typeof payload === "object") {
|
if (payload && typeof payload === "object") {
|
||||||
const anyPayload = payload as any;
|
const source = payload as Record<string, unknown>;
|
||||||
if (typeof anyPayload.exists === "boolean") return anyPayload.exists;
|
if (typeof source.exists === "boolean") return source.exists;
|
||||||
if (typeof anyPayload.exists === "number") return anyPayload.exists !== 0;
|
if (typeof source.exists === "number") return source.exists !== 0;
|
||||||
if (typeof anyPayload.is_exists === "boolean") return anyPayload.is_exists;
|
if (typeof source.is_exists === "boolean") return source.is_exists;
|
||||||
if (typeof anyPayload.is_exists === "number") return anyPayload.is_exists !== 0;
|
if (typeof source.is_exists === "number") return source.is_exists !== 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
};
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// FrontEndUser is the primary FE and follows BackEndGo cookie-based auth.
|
|
||||||
// Users sign in via the app's /signin page; the editor reuses those httpOnly cookies.
|
|
||||||
// This component remains as a no-op placeholder for any legacy imports.
|
|
||||||
export default function AuthPanel() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
+60
-426
@@ -1,25 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import { useState } from "react";
|
||||||
import type { UndoAction } from "@/uhm/lib/useEditorState";
|
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
import { ProjectPanel } from "./editor/ProjectPanel";
|
||||||
|
import { ToolsPanel } from "./editor/ToolsPanel";
|
||||||
|
import { CommitPanel } from "./editor/CommitPanel";
|
||||||
|
import { CommitHistoryPanel } from "./editor/CommitHistoryPanel";
|
||||||
|
import { UndoListPanel } from "./editor/UndoListPanel";
|
||||||
|
import { SubmitModal } from "./editor/SubmitModal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: EditorMode;
|
mode: EditorMode;
|
||||||
setMode: (mode: EditorMode) => void;
|
setMode: (mode: EditorMode) => void;
|
||||||
entityStatus?: string | null;
|
entityStatus?: string | null;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onSubmit: () => void;
|
onSubmit: (content: string) => void;
|
||||||
onRestoreCommit: (commitId: string) => void;
|
onRestoreCommit: (commitId: string) => void;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
sectionTitle: string;
|
sectionTitle: string;
|
||||||
sectionStatus: string;
|
projectStatus: string;
|
||||||
commitTitle: string;
|
commitTitle: string;
|
||||||
commitNote: string;
|
|
||||||
onCommitTitleChange: (title: string) => void;
|
onCommitTitleChange: (title: string) => void;
|
||||||
onCommitNoteChange: (note: string) => void;
|
|
||||||
commitCount: number;
|
commitCount: number;
|
||||||
hasHeadCommit: boolean;
|
hasHeadCommit: boolean;
|
||||||
headCommitId: string | null;
|
headCommitId: string | null;
|
||||||
@@ -32,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,11 +51,9 @@ export default function Editor({
|
|||||||
isSaving,
|
isSaving,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
sectionTitle,
|
sectionTitle,
|
||||||
sectionStatus,
|
projectStatus,
|
||||||
commitTitle,
|
commitTitle,
|
||||||
commitNote,
|
|
||||||
onCommitTitleChange,
|
onCommitTitleChange,
|
||||||
onCommitNoteChange,
|
|
||||||
commitCount,
|
commitCount,
|
||||||
hasHeadCommit,
|
hasHeadCommit,
|
||||||
headCommitId,
|
headCommitId,
|
||||||
@@ -68,57 +61,24 @@ export default function Editor({
|
|||||||
commits,
|
commits,
|
||||||
changesCount,
|
changesCount,
|
||||||
undoStack,
|
undoStack,
|
||||||
createdEntities,
|
|
||||||
createdGeometries,
|
|
||||||
width = 280,
|
width = 280,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const toggleMode = (newMode: EditorMode) => {
|
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
|
||||||
if (mode === newMode) {
|
const [submitContent, setSubmitContent] = useState("");
|
||||||
setMode("idle");
|
|
||||||
} else {
|
const handleOpenSubmitModal = () => {
|
||||||
setMode(newMode);
|
setSubmitContent("");
|
||||||
}
|
setIsSubmitModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const recentUndoLabels = (() => {
|
const handleConfirmSubmit = () => {
|
||||||
const seen = new Set<string>();
|
setIsSubmitModalOpen(false);
|
||||||
const labels: string[] = [];
|
onSubmit(submitContent);
|
||||||
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
|
};
|
||||||
const label = formatUndoLabel(undoStack[i]);
|
|
||||||
if (seen.has(label)) continue;
|
|
||||||
seen.add(label);
|
|
||||||
labels.push(label);
|
|
||||||
}
|
|
||||||
return labels.reverse();
|
|
||||||
})();
|
|
||||||
|
|
||||||
const formatCommitTitle = (commit: Props["commits"][number]) =>
|
const handleCancelSubmit = () => {
|
||||||
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
setIsSubmitModalOpen(false);
|
||||||
|
};
|
||||||
const modeButtonStyle = (btnMode: EditorMode) =>
|
|
||||||
({
|
|
||||||
padding: "8px 10px",
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "1px solid #334155",
|
|
||||||
background: mode === btnMode ? "#16a34a" : "#111827",
|
|
||||||
color: "white",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: 12,
|
|
||||||
minHeight: 34,
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}) as const;
|
|
||||||
|
|
||||||
const primaryButtonStyle =
|
|
||||||
({
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 10px",
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontWeight: 850,
|
|
||||||
fontSize: 12,
|
|
||||||
}) as const;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -133,77 +93,19 @@ export default function Editor({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0b1220", paddingBottom: 10 }}>
|
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0b1220", paddingBottom: 10 }}>
|
||||||
<div style={{ fontWeight: 950, fontSize: 14, marginBottom: 10 }}>Editor</div>
|
|
||||||
|
|
||||||
<Panel title="Project" defaultOpen>
|
<ProjectPanel
|
||||||
<div style={{ fontSize: 12, color: "#cbd5e1", lineHeight: 1.4 }}>
|
sectionTitle={sectionTitle}
|
||||||
<div style={{ color: "white", fontWeight: 850, overflowWrap: "anywhere" }}>{sectionTitle}</div>
|
projectStatus={projectStatus}
|
||||||
<div style={{ marginTop: 6 }}>
|
commitCount={commitCount}
|
||||||
Status: <span style={{ color: "#e2e8f0" }}>{sectionStatus}</span>
|
latestCommitLabel={latestCommitLabel}
|
||||||
</div>
|
/>
|
||||||
<div style={{ marginTop: 6 }}>
|
|
||||||
Commits: <span style={{ color: "#e2e8f0" }}>{commitCount}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 6 }}>
|
|
||||||
{latestCommitLabel ? (
|
|
||||||
<span style={{ color: "#e2e8f0" }}>{latestCommitLabel}</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: "#94a3b8" }}>Chưa có head commit</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel title="Tools" defaultOpen>
|
<ToolsPanel
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
mode={mode}
|
||||||
<button style={modeButtonStyle("select")} onClick={() => toggleMode("select")} title="Select">
|
setMode={setMode}
|
||||||
Select
|
onUndo={onUndo}
|
||||||
</button>
|
/>
|
||||||
<button style={modeButtonStyle("draw")} onClick={() => toggleMode("draw")} title="Draw polygon">
|
|
||||||
Draw
|
|
||||||
</button>
|
|
||||||
<button style={modeButtonStyle("add-point")} onClick={() => setMode("add-point")} title="Add point">
|
|
||||||
Point
|
|
||||||
</button>
|
|
||||||
<button style={modeButtonStyle("add-line")} onClick={() => setMode("add-line")} title="Add line">
|
|
||||||
Line
|
|
||||||
</button>
|
|
||||||
<button style={modeButtonStyle("add-path")} onClick={() => setMode("add-path")} title="Add path">
|
|
||||||
Path
|
|
||||||
</button>
|
|
||||||
<button style={modeButtonStyle("add-circle")} onClick={() => setMode("add-circle")} title="Add circle">
|
|
||||||
Circle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 10, fontSize: 12, color: "#94a3b8" }}>
|
|
||||||
Mode: <span style={{ color: "white", fontWeight: 850 }}>{mode}</span>
|
|
||||||
</div>
|
|
||||||
<ModeHint mode={mode} />
|
|
||||||
|
|
||||||
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
...modeButtonStyle("idle"),
|
|
||||||
background: "#111827",
|
|
||||||
}}
|
|
||||||
onClick={() => setMode("idle")}
|
|
||||||
title="Tắt tool hiện tại"
|
|
||||||
>
|
|
||||||
Idle
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
...modeButtonStyle("idle"),
|
|
||||||
background: "#334155",
|
|
||||||
}}
|
|
||||||
onClick={onUndo}
|
|
||||||
title="Undo thao tác gần nhất"
|
|
||||||
>
|
|
||||||
Undo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{entityStatus ? (
|
{entityStatus ? (
|
||||||
<div
|
<div
|
||||||
@@ -223,302 +125,34 @@ export default function Editor({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Panel title="Commit" defaultOpen>
|
<CommitPanel
|
||||||
<input
|
commitTitle={commitTitle}
|
||||||
value={commitTitle}
|
onCommitTitleChange={onCommitTitleChange}
|
||||||
onChange={(event) => onCommitTitleChange(event.target.value)}
|
isSaving={isSaving}
|
||||||
placeholder="Commit title"
|
isSubmitting={isSubmitting}
|
||||||
disabled={isSaving || isSubmitting}
|
changesCount={changesCount}
|
||||||
style={textInputStyle}
|
onCommit={onCommit}
|
||||||
|
hasHeadCommit={hasHeadCommit}
|
||||||
|
handleOpenSubmitModal={handleOpenSubmitModal}
|
||||||
/>
|
/>
|
||||||
<textarea
|
|
||||||
value={commitNote}
|
<CommitHistoryPanel
|
||||||
onChange={(event) => onCommitNoteChange(event.target.value)}
|
commits={commits}
|
||||||
placeholder="Commit note"
|
headCommitId={headCommitId}
|
||||||
disabled={isSaving || isSubmitting}
|
onRestoreCommit={onRestoreCommit}
|
||||||
rows={3}
|
isSaving={isSaving}
|
||||||
style={textAreaStyle}
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
...primaryButtonStyle,
|
|
||||||
marginTop: 8,
|
|
||||||
background: isSaving || isSubmitting || changesCount <= 0 ? "#475569" : "#0f766e",
|
|
||||||
cursor: isSaving || isSubmitting || changesCount <= 0 ? "not-allowed" : "pointer",
|
|
||||||
opacity: changesCount <= 0 ? 0.75 : 1,
|
|
||||||
}}
|
|
||||||
onClick={onCommit}
|
|
||||||
disabled={isSaving || isSubmitting || changesCount <= 0}
|
|
||||||
title={changesCount <= 0 ? "Khong co thay doi de commit" : undefined}
|
|
||||||
>
|
|
||||||
Commit ({changesCount})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
...primaryButtonStyle,
|
|
||||||
marginTop: 8,
|
|
||||||
background: isSubmitting || !hasHeadCommit ? "#475569" : "#16a34a",
|
|
||||||
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
|
|
||||||
opacity: !hasHeadCommit ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
onClick={onSubmit}
|
|
||||||
disabled={isSubmitting || !hasHeadCommit}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel title="Commit History" badge={String(commits.length)} defaultOpen={false}>
|
<UndoListPanel undoStack={undoStack} />
|
||||||
{commits.length === 0 ? (
|
|
||||||
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa có commit</div>
|
|
||||||
) : (
|
|
||||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
|
|
||||||
{commits.slice(0, 8).map((commit) => {
|
|
||||||
const isHead = Boolean(headCommitId && commit.id === headCommitId);
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={commit.id}
|
|
||||||
style={{
|
|
||||||
padding: "8px 0",
|
|
||||||
borderBottom: "1px solid #1f2937",
|
|
||||||
color: "#e2e8f0",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "row"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{flex:1}}>
|
|
||||||
<div
|
|
||||||
title={formatCommitTitle(commit)}
|
|
||||||
style={{
|
|
||||||
fontWeight: 750,
|
|
||||||
color: "#f8fafc",
|
|
||||||
overflowWrap: "anywhere",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatCommitTitle(commit)}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 3, color: "#94a3b8" }}>
|
|
||||||
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<SubmitModal
|
||||||
style={{
|
isSubmitModalOpen={isSubmitModalOpen}
|
||||||
marginTop: 6,
|
submitContent={submitContent}
|
||||||
padding: "6px 8px",
|
setSubmitContent={setSubmitContent}
|
||||||
borderRadius: 6,
|
handleCancelSubmit={handleCancelSubmit}
|
||||||
border: "1px solid #334155",
|
handleConfirmSubmit={handleConfirmSubmit}
|
||||||
background: isHead ? "#0b1220" : "#334155",
|
/>
|
||||||
color: "white",
|
|
||||||
cursor: isSaving || isSubmitting || isHead ? "not-allowed" : "pointer",
|
|
||||||
opacity: isHead ? 0.65 : 1,
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
onClick={() => onRestoreCommit(commit.id)}
|
|
||||||
disabled={isSaving || isSubmitting || isHead}
|
|
||||||
title={isHead ? "Đang là head commit" : "Restore snapshot từ commit này (FE-only)"}
|
|
||||||
>
|
|
||||||
Restore
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel title="Undo List" badge={String(recentUndoLabels.length)} defaultOpen={false}>
|
|
||||||
{recentUndoLabels.length === 0 ? (
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa có thao tác</div>
|
|
||||||
) : (
|
|
||||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 13, color: "#e2e8f0" }}>
|
|
||||||
{recentUndoLabels.map((label, idx) => (
|
|
||||||
<li key={`${label}-${idx}`} style={{ padding: "6px 0", borderBottom: "1px solid #1f2937" }}>
|
|
||||||
{label}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const textInputStyle = {
|
|
||||||
width: "100%",
|
|
||||||
marginTop: 0,
|
|
||||||
padding: "8px 10px",
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "1px solid #334155",
|
|
||||||
background: "#0b1220",
|
|
||||||
color: "white",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
fontSize: 13,
|
|
||||||
outline: "none",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const textAreaStyle = {
|
|
||||||
...textInputStyle,
|
|
||||||
marginTop: 8,
|
|
||||||
resize: "vertical",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function Panel({
|
|
||||||
title,
|
|
||||||
badge,
|
|
||||||
defaultOpen,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
badge?: string | null;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<details
|
|
||||||
open={Boolean(defaultOpen)}
|
|
||||||
style={{
|
|
||||||
marginTop: 10,
|
|
||||||
padding: 10,
|
|
||||||
background: "#111827",
|
|
||||||
borderRadius: 8,
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<summary
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
listStyle: "none",
|
|
||||||
fontWeight: 900,
|
|
||||||
fontSize: 13,
|
|
||||||
color: "white",
|
|
||||||
userSelect: "none",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{title}</span>
|
|
||||||
{badge ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
padding: "2px 8px",
|
|
||||||
borderRadius: 999,
|
|
||||||
border: "1px solid #334155",
|
|
||||||
background: "#0b1220",
|
|
||||||
color: "#cbd5e1",
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 850,
|
|
||||||
flex: "0 0 auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</summary>
|
|
||||||
<div style={{ marginTop: 10 }}>{children}</div>
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModeHint({ mode }: { mode: EditorMode }) {
|
|
||||||
if (mode === "add-line" || mode === "add-path") {
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
|
||||||
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (mode === "add-circle") {
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
|
||||||
Giữ chuột trái kéo để mở bán kính, thả chuột để hoàn tất.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (mode === "add-point") {
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
|
||||||
Chọn 1 điểm trên bản đồ để đặt địa điểm.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (mode === "select") {
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
|
||||||
Chọn 1 hình, đường, điểm trên bản đồ để xem chi tiết.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (mode === "draw") {
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
|
||||||
Chọn các điểm trên bản đồ để vẽ hình, ENTER để kết thúc, ESC để hủy.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUndoLabel(action: UndoAction) {
|
|
||||||
switch (action.type) {
|
|
||||||
case "create":
|
|
||||||
return `Thêm mới #${action.id}`;
|
|
||||||
case "delete":
|
|
||||||
return `Xóa #${action.feature.properties.id}`;
|
|
||||||
case "update":
|
|
||||||
return `Chỉnh sửa #${action.id}`;
|
|
||||||
case "properties":
|
|
||||||
return `Cập nhật thuộc tính #${action.id}`;
|
|
||||||
case "snapshot_entities":
|
|
||||||
case "snapshot_wikis":
|
|
||||||
case "snapshot_entity_wiki":
|
|
||||||
return action.label;
|
|
||||||
default:
|
|
||||||
return "Tác vụ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+179
-1603
File diff suppressed because it is too large
Load Diff
@@ -1,619 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type ComponentProps } from "react";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import "react-quill-new/dist/quill.snow.css";
|
|
||||||
|
|
||||||
import { Modal } from "@/components/ui/modal";
|
|
||||||
import Button from "@/components/ui/button/Button";
|
|
||||||
import Label from "@/components/form/Label";
|
|
||||||
|
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
|
||||||
import { newId } from "@/uhm/lib/id";
|
|
||||||
import type ReactQuill from "react-quill-new";
|
|
||||||
import { checkWikiSlugExists } from "@/uhm/api/wikis";
|
|
||||||
|
|
||||||
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
|
||||||
|
|
||||||
const ReactQuillEditor = dynamic<ReactQuillProps>(() => import("react-quill-new"), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <div className="h-[480px] w-full animate-pulse bg-gray-100 rounded-lg" />,
|
|
||||||
});
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
projectId: string;
|
|
||||||
wikis: WikiSnapshot[];
|
|
||||||
setWikis: React.Dispatch<React.SetStateAction<WikiSnapshot[]>>;
|
|
||||||
autoOpen?: boolean;
|
|
||||||
requestedActiveId?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function clampTitle(title: string) {
|
|
||||||
const t = title.trim();
|
|
||||||
return t.length ? t.slice(0, 120) : "Untitled wiki";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen, requestedActiveId }: Props) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const activeWiki = useMemo(() => wikis.find((w) => w.id === activeId) || null, [activeId, wikis]);
|
|
||||||
|
|
||||||
const [wikiTitle, setWikiTitle] = useState("");
|
|
||||||
const [wikiSlug, setWikiSlug] = useState("");
|
|
||||||
const [wikiDocHtml, setWikiDocHtml] = useState("");
|
|
||||||
const [wikiSaveError, setWikiSaveError] = useState<string | null>(null);
|
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
||||||
const [createTitle, setCreateTitle] = useState("");
|
|
||||||
const [createSlug, setCreateSlug] = useState("");
|
|
||||||
const [createSlugTouched, setCreateSlugTouched] = useState(false);
|
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
|
||||||
const [isCheckingCreateSlug, setIsCheckingCreateSlug] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoOpen) return;
|
|
||||||
// open once on mount
|
|
||||||
setOpen(true);
|
|
||||||
}, [autoOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!requestedActiveId) return;
|
|
||||||
if (wikis.some((w) => w.id === requestedActiveId)) {
|
|
||||||
setActiveId(requestedActiveId);
|
|
||||||
}
|
|
||||||
}, [requestedActiveId, wikis]);
|
|
||||||
|
|
||||||
// keep editor content in sync when switching wiki
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
setWikiTitle(activeWiki?.title || "");
|
|
||||||
setWikiSlug(typeof activeWiki?.slug === "string" ? activeWiki.slug : "");
|
|
||||||
setWikiDocHtml(normalizeWikiDocForQuill(activeWiki?.doc || null));
|
|
||||||
setWikiSaveError(null);
|
|
||||||
}, [activeWiki?.doc, activeWiki?.slug, activeWiki?.title, open]);
|
|
||||||
|
|
||||||
const ensureActive = () => {
|
|
||||||
if (activeId && wikis.some((w) => w.id === activeId)) return;
|
|
||||||
setActiveId(wikis[0]?.id || null);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
ensureActive();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [wikis.length]);
|
|
||||||
|
|
||||||
const openEditor = () => {
|
|
||||||
if (!wikis.length) {
|
|
||||||
const id = newId();
|
|
||||||
const seed: WikiSnapshot = {
|
|
||||||
id,
|
|
||||||
source: "inline",
|
|
||||||
operation: "create",
|
|
||||||
title: "Untitled wiki",
|
|
||||||
slug: null,
|
|
||||||
doc: "",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setWikis((prev) => [seed, ...prev]);
|
|
||||||
setActiveId(id);
|
|
||||||
}
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createWikiAndOpen = (title?: string, slug?: string | null) => {
|
|
||||||
const id = newId();
|
|
||||||
const seedTitle = clampTitle(title || "Untitled wiki");
|
|
||||||
const seed: WikiSnapshot = {
|
|
||||||
id,
|
|
||||||
source: "inline",
|
|
||||||
operation: "create",
|
|
||||||
title: seedTitle,
|
|
||||||
slug: slug ?? null,
|
|
||||||
doc: "",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setWikis((prev) => [seed, ...prev]);
|
|
||||||
setActiveId(id);
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateWikiFromPanel = async () => {
|
|
||||||
const title = clampTitle(createTitle);
|
|
||||||
const slug = normalizeWikiSlugInput(createSlug);
|
|
||||||
if (!slug) {
|
|
||||||
setCreateError("Slug la bat buoc. Hay thu mot slug khac.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCheckingCreateSlug(true);
|
|
||||||
setCreateError(null);
|
|
||||||
try {
|
|
||||||
const exists = await checkWikiSlugExists(slug);
|
|
||||||
if (exists) {
|
|
||||||
setCreateError("Slug da ton tai. Hay thu slug khac.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createWikiAndOpen(title, slug);
|
|
||||||
setCreateTitle("");
|
|
||||||
setCreateSlug("");
|
|
||||||
setCreateSlugTouched(false);
|
|
||||||
setIsCreateOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : "Khong check duoc slug.";
|
|
||||||
setCreateError(msg);
|
|
||||||
} finally {
|
|
||||||
setIsCheckingCreateSlug(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeWiki = (id: string) => {
|
|
||||||
setWikis((prev) => prev.filter((w) => w.id !== id));
|
|
||||||
if (activeId === id) setActiveId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveWiki = async () => {
|
|
||||||
if (!activeId) return;
|
|
||||||
const payload = wikiDocHtml;
|
|
||||||
const nextTitle = clampTitle(wikiTitle);
|
|
||||||
const nextSlug = normalizeWikiSlugInput(wikiSlug);
|
|
||||||
|
|
||||||
const current = wikis.find((w) => w.id === activeId) || null;
|
|
||||||
// Check uniqueness only when creating a brand-new wiki.
|
|
||||||
if (current?.operation === "create" && nextSlug) {
|
|
||||||
try {
|
|
||||||
const exists = await checkWikiSlugExists(nextSlug);
|
|
||||||
if (exists) {
|
|
||||||
setWikiSaveError("Slug da ton tai. Hay thu slug khac.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : "Khong check duoc slug.";
|
|
||||||
setWikiSaveError(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setWikiSaveError(null);
|
|
||||||
setWikis((prev) =>
|
|
||||||
prev.map((w) =>
|
|
||||||
w.id !== activeId
|
|
||||||
? w
|
|
||||||
: {
|
|
||||||
...w,
|
|
||||||
source: w.source,
|
|
||||||
operation: w.operation === "create" ? "create" : "update",
|
|
||||||
title: nextTitle,
|
|
||||||
slug: nextSlug,
|
|
||||||
doc: payload,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "10px",
|
|
||||||
background: "#0b1220",
|
|
||||||
borderRadius: "8px",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
|
||||||
<div style={{ fontWeight: 700, fontSize: "14px" }}>Wiki</div>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{wikis.length}</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCollapsed((v) => !v)}
|
|
||||||
title={collapsed ? "Mo panel" : "Thu gon panel"}
|
|
||||||
aria-label={collapsed ? "Mo panel Wiki" : "Thu gon panel Wiki"}
|
|
||||||
style={{
|
|
||||||
width: 26,
|
|
||||||
height: 26,
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "1px solid #334155",
|
|
||||||
background: "#0b1220",
|
|
||||||
color: "#e2e8f0",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flex: "0 0 auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{collapsed ? <PlusIcon /> : <MinusIcon />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{collapsed ? null : wikis.length ? (
|
|
||||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
|
||||||
{wikis.slice(0, 8).map((w) => (
|
|
||||||
<div
|
|
||||||
key={w.id}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
padding: "8px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #1f2937",
|
|
||||||
background: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveId(w.id);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
textAlign: "left",
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
color: "#e5e7eb",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "12px",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
}}
|
|
||||||
title={w.title}
|
|
||||||
>
|
|
||||||
{w.title}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeWiki(w.id)}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
background: "#111827",
|
|
||||||
color: "#fca5a5",
|
|
||||||
cursor: "pointer",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "6px 8px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
title="Remove"
|
|
||||||
>
|
|
||||||
Del
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{wikis.length > 8 ? (
|
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>+{wikis.length - 8} more…</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
|
||||||
No wiki yet for this project.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{collapsed ? null : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: "10px",
|
|
||||||
display: "grid",
|
|
||||||
gap: "8px",
|
|
||||||
border: "1px solid #1e3a8a",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "8px",
|
|
||||||
background: "#0f172a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
|
||||||
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
|
||||||
Tạo wiki mới
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setIsCreateOpen((v) => {
|
|
||||||
const next = !v;
|
|
||||||
if (next) {
|
|
||||||
setCreateError(null);
|
|
||||||
setIsCheckingCreateSlug(false);
|
|
||||||
setCreateSlugTouched(false);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
title={isCreateOpen ? "Dong" : "Mo"}
|
|
||||||
aria-label={isCreateOpen ? "Dong tao wiki" : "Mo tao wiki"}
|
|
||||||
style={{
|
|
||||||
width: 26,
|
|
||||||
height: 26,
|
|
||||||
borderRadius: 6,
|
|
||||||
border: "1px solid #334155",
|
|
||||||
background: "#0b1220",
|
|
||||||
color: "#e2e8f0",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flex: "0 0 auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isCreateOpen ? <CloseIcon /> : <PlusIcon />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isCreateOpen ? (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
value={createTitle}
|
|
||||||
onChange={(e) => {
|
|
||||||
const nextTitle = e.target.value;
|
|
||||||
setCreateTitle(nextTitle);
|
|
||||||
setCreateError(null);
|
|
||||||
if (!createSlugTouched) {
|
|
||||||
setCreateSlug(slugifyWikiTitle(nextTitle));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Tieu de wiki"
|
|
||||||
disabled={isCheckingCreateSlug}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
background: "#111827",
|
|
||||||
color: "#f8fafc",
|
|
||||||
padding: "6px 8px",
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={createSlug}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCreateSlugTouched(true);
|
|
||||||
setCreateSlug(e.target.value);
|
|
||||||
setCreateError(null);
|
|
||||||
}}
|
|
||||||
placeholder="Slug"
|
|
||||||
disabled={isCheckingCreateSlug}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
background: "#111827",
|
|
||||||
color: "#f8fafc",
|
|
||||||
padding: "6px 8px",
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCreateWikiFromPanel}
|
|
||||||
disabled={isCheckingCreateSlug}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "7px 8px",
|
|
||||||
cursor: isCheckingCreateSlug ? "not-allowed" : "pointer",
|
|
||||||
background: "#2563eb",
|
|
||||||
color: "#ffffff",
|
|
||||||
fontWeight: 600,
|
|
||||||
opacity: isCheckingCreateSlug ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Tạo wiki mới
|
|
||||||
</button>
|
|
||||||
{createError ? (
|
|
||||||
<div style={{ color: "#fca5a5", fontSize: 12 }}>
|
|
||||||
{createError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
isOpen={open}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
showCloseButton={false}
|
|
||||||
// Defensive: even if Modal defaults change, keep wiki popup free of the "X" close button.
|
|
||||||
className="max-w-[1100px] m-4 [&>button]:hidden"
|
|
||||||
>
|
|
||||||
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">Project</div>
|
|
||||||
<div className="text-sm font-mono break-all text-gray-700 dark:text-gray-200">{projectId}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={saveWiki} disabled={!activeId}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2">Wikis</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{wikis.map((w) => (
|
|
||||||
<button
|
|
||||||
key={w.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveId(w.id)}
|
|
||||||
className={`text-left rounded-xl border px-3 py-2 text-sm transition ${
|
|
||||||
w.id === activeId
|
|
||||||
? "border-brand-500 bg-brand-50 dark:bg-brand-500/10"
|
|
||||||
: "border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]"
|
|
||||||
}`}
|
|
||||||
title={w.title}
|
|
||||||
>
|
|
||||||
<div className="font-medium truncate">{w.title}</div>
|
|
||||||
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{w.id}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<Button size="sm" variant="outline" onClick={openEditor}>
|
|
||||||
+ New wiki
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:col-span-3">
|
|
||||||
<div className="grid grid-cols-1 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label>Title</Label>
|
|
||||||
<input
|
|
||||||
value={wikiTitle}
|
|
||||||
onChange={(e) => setWikiTitle(e.target.value)}
|
|
||||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
|
||||||
placeholder="Wiki title"
|
|
||||||
disabled={!activeId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Slug</Label>
|
|
||||||
<input
|
|
||||||
value={wikiSlug}
|
|
||||||
onChange={(e) => setWikiSlug(e.target.value)}
|
|
||||||
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
|
||||||
placeholder="wiki-slug"
|
|
||||||
disabled={!activeId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{wikiSaveError ? (
|
|
||||||
<div className="text-xs text-red-600 dark:text-red-300">
|
|
||||||
{wikiSaveError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] overflow-hidden">
|
|
||||||
<ReactQuillEditor
|
|
||||||
theme="snow"
|
|
||||||
value={wikiDocHtml}
|
|
||||||
onChange={(content: string) => setWikiDocHtml(content)}
|
|
||||||
modules={QUILL_MODULES}
|
|
||||||
className="min-h-[320px]"
|
|
||||||
placeholder="Nhap noi dung wiki..."
|
|
||||||
readOnly={!activeId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Stored in snapshot_json on commit. This page does not write to DB yet.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlusIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path d="M12 5v14M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MinusIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path d="M5 12h14" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CloseIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path d="M6 6l12 12M18 6L6 18" stroke="#e2e8f0" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const QUILL_MODULES = {
|
|
||||||
toolbar: [
|
|
||||||
[{ header: [1, 2, 3, false] }],
|
|
||||||
["bold", "italic", "underline", "strike"],
|
|
||||||
[{ list: "ordered" }, { list: "bullet" }],
|
|
||||||
["blockquote", "code-block"],
|
|
||||||
["link", "image"],
|
|
||||||
["clean"],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeWikiDocForQuill(doc: string | null): string {
|
|
||||||
const raw = (doc || "").trim();
|
|
||||||
if (!raw.length) return "";
|
|
||||||
|
|
||||||
// New format (Quill): HTML string.
|
|
||||||
if (raw[0] === "<") return raw;
|
|
||||||
|
|
||||||
// Legacy format (Tiptap): JSON string.
|
|
||||||
if (raw[0] === "{") {
|
|
||||||
try {
|
|
||||||
const json: unknown = JSON.parse(raw);
|
|
||||||
const text = tiptapJsonToPlainText(json).trim();
|
|
||||||
if (!text.length) return "";
|
|
||||||
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
|
|
||||||
} catch {
|
|
||||||
// fall through
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown plaintext: treat as plain text.
|
|
||||||
return `<p>${escapeHtml(raw).replace(/\n/g, "<br/>")}</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeWikiSlugInput(raw: string): string | null {
|
|
||||||
const s = raw.trim();
|
|
||||||
return s.length ? s : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugifyWikiTitle(raw: string): string {
|
|
||||||
const input = String(raw || "").trim();
|
|
||||||
if (!input.length) return "";
|
|
||||||
return input
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize("NFKD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+/, "")
|
|
||||||
.replace(/-+$/, "")
|
|
||||||
.slice(0, 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tiptapJsonToPlainText(node: unknown): string {
|
|
||||||
if (node == null) return "";
|
|
||||||
if (typeof node === "string") return node;
|
|
||||||
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
|
|
||||||
|
|
||||||
if (isRecord(node)) {
|
|
||||||
if (node.type === "text" && typeof node.text === "string") return node.text;
|
|
||||||
if (node.type === "hardBreak") return "\n";
|
|
||||||
if ("content" in node) return tiptapJsonToPlainText(node.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(input: string): string {
|
|
||||||
return input
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll("\"", """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
}
|
|
||||||
+45
-1
@@ -5,13 +5,16 @@ import {
|
|||||||
BACKGROUND_LAYER_OPTIONS,
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
BackgroundLayerId,
|
BackgroundLayerId,
|
||||||
BackgroundLayerVisibility,
|
BackgroundLayerVisibility,
|
||||||
} from "@/uhm/lib/backgroundLayers";
|
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
|
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visibility: BackgroundLayerVisibility;
|
visibility: BackgroundLayerVisibility;
|
||||||
onToggleLayer: (id: BackgroundLayerId) => void;
|
onToggleLayer: (id: BackgroundLayerId) => void;
|
||||||
onShowAll: () => void;
|
onShowAll: () => void;
|
||||||
onHideAll: () => void;
|
onHideAll: () => void;
|
||||||
|
geometryVisibility?: Record<string, boolean>;
|
||||||
|
onToggleGeometryType?: (typeKey: string) => void;
|
||||||
topContent?: ReactNode;
|
topContent?: ReactNode;
|
||||||
width?: number;
|
width?: number;
|
||||||
};
|
};
|
||||||
@@ -21,6 +24,8 @@ export default function BackgroundLayersPanel({
|
|||||||
onToggleLayer,
|
onToggleLayer,
|
||||||
onShowAll,
|
onShowAll,
|
||||||
onHideAll,
|
onHideAll,
|
||||||
|
geometryVisibility,
|
||||||
|
onToggleGeometryType,
|
||||||
topContent,
|
topContent,
|
||||||
width = 240,
|
width = 240,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -69,6 +74,45 @@ export default function BackgroundLayersPanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{geometryVisibility && onToggleGeometryType ? (
|
||||||
|
<>
|
||||||
|
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
|
||||||
|
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
|
||||||
|
Geometries
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
|
||||||
|
{GEO_TYPE_KEYS.map((typeKey) => {
|
||||||
|
const on = geometryVisibility[typeKey] !== false;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={typeKey}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleGeometryType(typeKey)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: on ? "#22c55e" : "#e5e7eb",
|
||||||
|
textDecorationLine: on ? "none" : "line-through",
|
||||||
|
textDecorationThickness: on ? undefined : "2px",
|
||||||
|
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 750,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
textTransform: "capitalize",
|
||||||
|
}}
|
||||||
|
title={on ? "On" : "Off"}
|
||||||
|
>
|
||||||
|
{typeKey.replaceAll("_", " ")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Panel } from "./Panel";
|
||||||
|
|
||||||
|
type Commit = {
|
||||||
|
id: string;
|
||||||
|
created_at?: string;
|
||||||
|
edit_summary: string;
|
||||||
|
user_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommitHistoryPanelProps = {
|
||||||
|
commits: Commit[];
|
||||||
|
headCommitId: string | null;
|
||||||
|
onRestoreCommit: (commitId: string) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CommitHistoryPanel({
|
||||||
|
commits,
|
||||||
|
headCommitId,
|
||||||
|
onRestoreCommit,
|
||||||
|
isSaving,
|
||||||
|
isSubmitting,
|
||||||
|
}: CommitHistoryPanelProps) {
|
||||||
|
const formatCommitTitle = (commit: Commit) =>
|
||||||
|
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title="Commit History" badge={String(commits.length)} defaultOpen={false}>
|
||||||
|
{commits.length === 0 ? (
|
||||||
|
<div style={{ color: "#64748b", fontSize: 12 }}>Chưa có commit</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 12 }}>
|
||||||
|
{commits.slice(0, 8).map((commit) => {
|
||||||
|
const isHead = Boolean(headCommitId && commit.id === headCommitId);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={commit.id}
|
||||||
|
style={{
|
||||||
|
padding: "8px 0",
|
||||||
|
borderBottom: "1px solid #1f2937",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{flex:1}}>
|
||||||
|
<div
|
||||||
|
title={formatCommitTitle(commit)}
|
||||||
|
style={{
|
||||||
|
fontWeight: 750,
|
||||||
|
color: "#f8fafc",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCommitTitle(commit)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 3, color: "#94a3b8" }}>
|
||||||
|
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
padding: "6px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: isHead ? "#0b1220" : "#334155",
|
||||||
|
color: "white",
|
||||||
|
cursor: isSaving || isSubmitting || isHead ? "not-allowed" : "pointer",
|
||||||
|
opacity: isHead ? 0.65 : 1,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
onClick={() => onRestoreCommit(commit.id)}
|
||||||
|
disabled={isSaving || isSubmitting || isHead}
|
||||||
|
title={isHead ? "Đang là head commit" : "Restore snapshot từ commit này (FE-only)"}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Panel } from "./Panel";
|
||||||
|
|
||||||
|
type CommitPanelProps = {
|
||||||
|
commitTitle: string;
|
||||||
|
onCommitTitleChange: (title: string) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
changesCount: number;
|
||||||
|
onCommit: () => void;
|
||||||
|
hasHeadCommit: boolean;
|
||||||
|
handleOpenSubmitModal: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CommitPanel({
|
||||||
|
commitTitle,
|
||||||
|
onCommitTitleChange,
|
||||||
|
isSaving,
|
||||||
|
isSubmitting,
|
||||||
|
changesCount,
|
||||||
|
onCommit,
|
||||||
|
hasHeadCommit,
|
||||||
|
handleOpenSubmitModal,
|
||||||
|
}: CommitPanelProps) {
|
||||||
|
const primaryButtonStyle = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 850,
|
||||||
|
fontSize: 12,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const textInputStyle = {
|
||||||
|
width: "100%",
|
||||||
|
marginTop: 0,
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "white",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
fontSize: 13,
|
||||||
|
outline: "none",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title="Commit" defaultOpen>
|
||||||
|
<input
|
||||||
|
value={commitTitle}
|
||||||
|
onChange={(event) => onCommitTitleChange(event.target.value)}
|
||||||
|
placeholder="Edit Summary (Commit Title)"
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
style={textInputStyle}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...primaryButtonStyle,
|
||||||
|
marginTop: 8,
|
||||||
|
background: isSaving || isSubmitting || changesCount <= 0 ? "#475569" : "#0f766e",
|
||||||
|
cursor: isSaving || isSubmitting || changesCount <= 0 ? "not-allowed" : "pointer",
|
||||||
|
opacity: changesCount <= 0 ? 0.75 : 1,
|
||||||
|
}}
|
||||||
|
onClick={onCommit}
|
||||||
|
disabled={isSaving || isSubmitting || changesCount <= 0}
|
||||||
|
title={changesCount <= 0 ? "Khong co thay doi de commit" : undefined}
|
||||||
|
>
|
||||||
|
Commit ({changesCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...primaryButtonStyle,
|
||||||
|
marginTop: 8,
|
||||||
|
background: isSubmitting || !hasHeadCommit ? "#475569" : "#16a34a",
|
||||||
|
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
|
||||||
|
opacity: !hasHeadCommit ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
onClick={handleOpenSubmitModal}
|
||||||
|
disabled={isSubmitting || !hasHeadCommit}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
+174
-24
@@ -1,12 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { Entity } from "@/uhm/types/entities";
|
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
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; operation?: 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[];
|
||||||
@@ -29,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), operation: w.operation })),
|
.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
title: wikiTitle(w),
|
||||||
|
isNew: w.source === "inline" && w.operation === "create",
|
||||||
|
})),
|
||||||
[wikis]
|
[wikis]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -39,17 +52,6 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
}, [entities]);
|
}, [entities]);
|
||||||
|
|
||||||
// Don't auto-select entity. The user must explicitly pick one.
|
|
||||||
// Only clear the selection if the currently selected entity is no longer available.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeEntityId) return;
|
|
||||||
const stillExists = entityChoices.some((e) => e.id === activeEntityId);
|
|
||||||
if (!stillExists) {
|
|
||||||
setActiveEntityId("");
|
|
||||||
setActiveWikiId("");
|
|
||||||
}
|
|
||||||
}, [activeEntityId, entityChoices]);
|
|
||||||
|
|
||||||
const activeLinks = useMemo(() => {
|
const activeLinks = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const l of links || []) {
|
for (const l of links || []) {
|
||||||
@@ -60,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();
|
||||||
@@ -81,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
|
||||||
@@ -94,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)}
|
||||||
@@ -144,6 +182,13 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{activeEntityId ? (
|
||||||
|
<ActiveSelectionLabel
|
||||||
|
label={activeEntityChoice?.name || activeEntityId}
|
||||||
|
id={activeEntityId}
|
||||||
|
isNew={Boolean(activeEntityChoice?.isNew)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -175,6 +220,13 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{activeWikiChoice ? (
|
||||||
|
<ActiveSelectionLabel
|
||||||
|
label={activeWikiChoice.title}
|
||||||
|
id={activeWikiChoice.id}
|
||||||
|
isNew={Boolean(activeWikiChoice.isNew)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{wikiChoices.length === 0 ? (
|
{wikiChoices.length === 0 ? (
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
|
<div style={{ fontSize: "12px", color: "#94a3b8" }}>No wiki in project yet.</div>
|
||||||
@@ -208,9 +260,9 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
{!activeEntityId ? (
|
{!activeEntityId ? (
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>Pick an entity to see/link wikis.</div>
|
||||||
) : activeLinks.size ? (
|
) : activeLinks.size ? (
|
||||||
<div style={{ display: "grid", gap: "6px" }}>
|
<div style={{ display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>Linked wikis ({activeLinks.size})</div>
|
||||||
{Array.from(activeLinks).slice(0, 8).map((id) => {
|
{Array.from(activeLinks).map((id) => {
|
||||||
const w = wikiChoices.find((x) => x.id === id) || null;
|
const w = wikiChoices.find((x) => x.id === id) || null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -228,7 +280,8 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
title={id}
|
title={id}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div
|
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||||
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: "#e5e7eb",
|
color: "#e5e7eb",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -239,6 +292,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{w?.title || "Untitled wiki"}
|
{w?.title || "Untitled wiki"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
{id}
|
{id}
|
||||||
@@ -264,9 +318,7 @@ export default function EntityWikiBindingsPanel({ entities, wikis, links, setLin
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{activeLinks.size > 8 ? (
|
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>+{activeLinks.size - 8} more…</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
|
<div style={{ fontSize: 12, color: "#94a3b8" }}>No wiki linked yet.</div>
|
||||||
@@ -275,12 +327,110 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActiveSelectionLabel({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
isNew,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
isNew?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ color: "#cbd5e1", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#64748b", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{id}
|
||||||
|
</span>
|
||||||
|
{isNew ? <NewBadge /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PlusIcon() {
|
function PlusIcon() {
|
||||||
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">
|
||||||
+127
-13
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, type CSSProperties, type KeyboardEvent } from "react";
|
||||||
|
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||||
|
|
||||||
type GeometryChoice = {
|
type GeometryChoice = {
|
||||||
id: string;
|
id: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
isNew?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,6 +14,7 @@ type Props = {
|
|||||||
selectedGeometryId: string | null;
|
selectedGeometryId: string | null;
|
||||||
selectedGeometryBindingIds: string[];
|
selectedGeometryBindingIds: string[];
|
||||||
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
onToggleBindGeometryForSelectedGeometry?: (geometryId: string, nextChecked: boolean) => void;
|
||||||
|
onFocusGeometry?: (geometryId: string) => void;
|
||||||
statusText?: string | null;
|
statusText?: string | null;
|
||||||
bindingFilterEnabled: boolean;
|
bindingFilterEnabled: boolean;
|
||||||
onBindingFilterEnabledChange: (next: boolean) => void;
|
onBindingFilterEnabledChange: (next: boolean) => void;
|
||||||
@@ -22,26 +25,47 @@ export default function GeometryBindingPanel({
|
|||||||
selectedGeometryId,
|
selectedGeometryId,
|
||||||
selectedGeometryBindingIds,
|
selectedGeometryBindingIds,
|
||||||
onToggleBindGeometryForSelectedGeometry,
|
onToggleBindGeometryForSelectedGeometry,
|
||||||
|
onFocusGeometry,
|
||||||
statusText,
|
statusText,
|
||||||
bindingFilterEnabled,
|
bindingFilterEnabled,
|
||||||
onBindingFilterEnabledChange,
|
onBindingFilterEnabledChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const canBindToggle =
|
const canBindToggle =
|
||||||
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
Boolean(selectedGeometryId) && typeof onToggleBindGeometryForSelectedGeometry === "function";
|
||||||
|
const canFocusGeometry = typeof onFocusGeometry === "function";
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const cleaned = (geometries || [])
|
const cleaned = (geometries || [])
|
||||||
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
|
.filter((g) => g && typeof g.id === "string" && g.id.trim().length > 0)
|
||||||
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim() }));
|
.map((g) => ({ id: g.id.trim(), label: (g.label || "").trim(), isNew: Boolean(g.isNew) }));
|
||||||
cleaned.sort((a, b) => a.id.localeCompare(b.id));
|
cleaned.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}, [geometries]);
|
}, [geometries]);
|
||||||
|
|
||||||
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
const bindingSet = useMemo(() => new Set(selectedGeometryBindingIds || []), [selectedGeometryBindingIds]);
|
||||||
|
const selectedGeometry = useMemo(() => {
|
||||||
|
if (!selectedGeometryId) return null;
|
||||||
|
return rows.find((g) => g.id === selectedGeometryId) || null;
|
||||||
|
}, [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 visibleRows = rows.slice(0, 12);
|
const handleFocusKeyDown = (event: KeyboardEvent<HTMLDivElement>, geometryId: string) => {
|
||||||
|
if (!canFocusGeometry) return;
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
onFocusGeometry?.(geometryId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -100,10 +124,67 @@ export default function GeometryBindingPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{collapsed ? null : selectedGeometry ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid rgba(59, 130, 246, 0.45)",
|
||||||
|
background: "rgba(37, 99, 235, 0.12)",
|
||||||
|
cursor: canFocusGeometry ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
title={selectedGeometry.id}
|
||||||
|
role={canFocusGeometry ? "button" : undefined}
|
||||||
|
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||||
|
onClick={() => onFocusGeometry?.(selectedGeometry.id)}
|
||||||
|
onKeyDown={(event) => handleFocusKeyDown(event, selectedGeometry.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#93c5fd",
|
||||||
|
fontWeight: 900,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
lineHeight: 1,
|
||||||
|
marginBottom: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Selected
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedGeometry.label || selectedGeometry.id}
|
||||||
|
</span>
|
||||||
|
{selectedGeometry.isNew ? <NewBadge /> : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 3,
|
||||||
|
fontSize: "11px",
|
||||||
|
color: "#94a3b8",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedGeometry.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{collapsed ? null : rows.length ? (
|
{collapsed ? null : rows.length ? (
|
||||||
<div style={{ marginTop: "10px", display: "grid", gap: "6px" }}>
|
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||||
{visibleRows
|
{visibleRows
|
||||||
.filter((g) => g.id !== selectedGeometryId)
|
|
||||||
.map((g) => {
|
.map((g) => {
|
||||||
const isBound = bindingSet.has(g.id);
|
const isBound = bindingSet.has(g.id);
|
||||||
return (
|
return (
|
||||||
@@ -112,17 +193,30 @@ 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,
|
||||||
|
cursor: canFocusGeometry ? "pointer" : "default",
|
||||||
opacity: canBindToggle ? 1 : 0.75,
|
opacity: canBindToggle ? 1 : 0.75,
|
||||||
}}
|
}}
|
||||||
title={g.id}
|
title={g.id}
|
||||||
|
role={canFocusGeometry ? "button" : undefined}
|
||||||
|
tabIndex={canFocusGeometry ? 0 : undefined}
|
||||||
|
onClick={() => onFocusGeometry?.(g.id)}
|
||||||
|
onKeyDown={(event) => handleFocusKeyDown(event, g.id)}
|
||||||
>
|
>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
color: "#e5e7eb",
|
color: "#e5e7eb",
|
||||||
@@ -133,6 +227,9 @@ export default function GeometryBindingPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{g.label || g.id}
|
{g.label || g.id}
|
||||||
|
</span>
|
||||||
|
{isBound ? <span style={boundBadgeStyle}>bound</span> : null}
|
||||||
|
{g.isNew ? <NewBadge /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -151,7 +248,10 @@ export default function GeometryBindingPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={isBound ? "Unbind from selected geometry" : "Bind to selected geometry"}
|
title={isBound ? "Unbind from selected geometry" : "Bind to selected geometry"}
|
||||||
onClick={() => onToggleBindGeometryForSelectedGeometry!(g.id, !isBound)}
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleBindGeometryForSelectedGeometry!(g.id, !isBound);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -176,11 +276,7 @@ export default function GeometryBindingPanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{rows.length > visibleRows.length ? (
|
|
||||||
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
|
|
||||||
+{rows.length - visibleRows.length} more…
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>
|
||||||
@@ -197,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">
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
export function ModeHint({ mode }: { mode: EditorMode }) {
|
||||||
|
if (mode === "add-line" || mode === "add-path") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === "add-circle") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Giữ chuột trái kéo để mở bán kính, thả chuột để hoàn tất.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === "add-point") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Chọn 1 điểm trên bản đồ để đặt địa điểm.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mode === "select") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Chọn 1 hình, đường, điểm trên bản đồ để xem chi tiết.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (mode === "draw") {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||||
|
Chọn các điểm trên bản đồ để vẽ hình, ENTER để kết thúc, ESC để hủy.
|
||||||
|
</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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type PanelProps = {
|
||||||
|
title: string;
|
||||||
|
badge?: string | null;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Panel({
|
||||||
|
title,
|
||||||
|
badge,
|
||||||
|
defaultOpen,
|
||||||
|
children,
|
||||||
|
}: PanelProps) {
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
open={Boolean(defaultOpen)}
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
padding: 10,
|
||||||
|
background: "#111827",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
listStyle: "none",
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "white",
|
||||||
|
userSelect: "none",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{title}</span>
|
||||||
|
{badge ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "#cbd5e1",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 850,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: 10 }}>{children}</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
+78
-30
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
import { useMemo, useState, type CSSProperties } from "react";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { EntityFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import NewBadge from "@/uhm/components/editor/NewBadge";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
entityRefs: EntitySnapshot[];
|
entityRefs: EntitySnapshot[];
|
||||||
@@ -38,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),
|
||||||
@@ -46,18 +63,11 @@ export default function ProjectEntityRefsPanel({
|
|||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editDescription, setEditDescription] = useState("");
|
const [editDescription, setEditDescription] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
const openEntityEditor = (entity: EntitySnapshot) => {
|
||||||
if (!activeEntityId) return;
|
setActiveEntityId(String(entity.id));
|
||||||
if (!entityRefs.some((e) => String(e.id) === String(activeEntityId))) {
|
setEditName(typeof entity.name === "string" ? entity.name : "");
|
||||||
setActiveEntityId(null);
|
setEditDescription(entity.description == null ? "" : String(entity.description));
|
||||||
}
|
};
|
||||||
}, [activeEntityId, entityRefs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeEntity) return;
|
|
||||||
setEditName(typeof activeEntity.name === "string" ? activeEntity.name : "");
|
|
||||||
setEditDescription(activeEntity.description == null ? "" : String(activeEntity.description));
|
|
||||||
}, [activeEntity?.description, activeEntity?.id, activeEntity?.name]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -96,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" }}>
|
<div style={{ marginTop: "10px", display: "grid", gap: "6px", maxHeight: 250, overflowY: "auto", paddingRight: 4 }}>
|
||||||
{entityRefs.slice(0, 8).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,
|
||||||
@@ -113,7 +131,7 @@ export default function ProjectEntityRefsPanel({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveEntityId(String(e.id))}
|
onClick={() => openEntityEditor(e)}
|
||||||
title="Chon de sua"
|
title="Chon de sua"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -126,8 +144,12 @@ export default function ProjectEntityRefsPanel({
|
|||||||
}}
|
}}
|
||||||
disabled={!canEditEntity}
|
disabled={!canEditEntity}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: "12px", color: "#e5e7eb", fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
{e.name || e.id}
|
{e.name || e.id}
|
||||||
|
</span>
|
||||||
|
{isBoundToSelectedGeometry ? <span style={boundBadgeStyle}>bound</span> : 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" }}>
|
||||||
{e.id}
|
{e.id}
|
||||||
@@ -136,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={{
|
||||||
@@ -156,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 />
|
||||||
@@ -169,8 +191,9 @@ export default function ProjectEntityRefsPanel({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
{entityRefs.length > 8 ? <div style={{ fontSize: "12px", color: "#94a3b8" }}>+{entityRefs.length - 8} more…</div> : null}
|
})}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
<div style={{ marginTop: "10px", fontSize: "12px", color: "#94a3b8" }}>No entity ref yet for this project.</div>
|
||||||
@@ -189,8 +212,11 @@ export default function ProjectEntityRefsPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
<div style={{ color: "#a7f3d0", fontWeight: 700, fontSize: "12px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ color: "#a7f3d0", fontWeight: 700, fontSize: "12px" }}>
|
||||||
Sua entity
|
Sua entity
|
||||||
|
</span>
|
||||||
|
{isNewEntityRef(activeEntity) ? <NewBadge /> : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -344,6 +370,10 @@ export default function ProjectEntityRefsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNewEntityRef(entity: EntitySnapshot | null | undefined): boolean {
|
||||||
|
return entity?.source === "inline" && entity?.operation === "create";
|
||||||
|
}
|
||||||
|
|
||||||
const entityInputStyle: CSSProperties = {
|
const entityInputStyle: CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
@@ -354,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">
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Panel } from "./Panel";
|
||||||
|
|
||||||
|
type ProjectPanelProps = {
|
||||||
|
sectionTitle: string;
|
||||||
|
projectStatus: string;
|
||||||
|
commitCount: number;
|
||||||
|
latestCommitLabel: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProjectPanel({
|
||||||
|
sectionTitle,
|
||||||
|
projectStatus,
|
||||||
|
commitCount,
|
||||||
|
latestCommitLabel,
|
||||||
|
}: ProjectPanelProps) {
|
||||||
|
return (
|
||||||
|
<Panel title="Project" defaultOpen>
|
||||||
|
<div style={{ fontSize: 12, color: "#cbd5e1", lineHeight: 1.4 }}>
|
||||||
|
<div style={{ color: "white", fontWeight: 850, overflowWrap: "anywhere" }}>{sectionTitle}</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
Status: <span style={{ color: "#e2e8f0" }}>{projectStatus}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
Commits: <span style={{ color: "#e2e8f0" }}>{commitCount}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
{latestCommitLabel ? (
|
||||||
|
<span style={{ color: "#e2e8f0" }}>{latestCommitLabel}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "#94a3b8" }}>Chưa có head commit</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
+25
-115
@@ -1,25 +1,20 @@
|
|||||||
"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/useEditorState";
|
|
||||||
import {
|
import {
|
||||||
EntityGeometryPreset,
|
GeometryPreset,
|
||||||
EntityTypeGroupId,
|
GeometryTypeGroupId,
|
||||||
EntityTypeOption,
|
GeometryTypeOption,
|
||||||
findEntityTypeOption,
|
findGeometryTypeOption,
|
||||||
groupEntityTypeOptions,
|
groupGeometryTypeOptions,
|
||||||
} from "@/uhm/lib/entityTypeOptions";
|
} 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 = {
|
||||||
selectedFeature: Feature | null;
|
selectedFeatures: Feature[];
|
||||||
selectedFeatureEntitySummary: string;
|
entityTypeOptions: GeometryTypeOption[];
|
||||||
selectedFeatureBindingSummary: string;
|
|
||||||
entities: Entity[];
|
|
||||||
selectedGeometryEntityIds: string[];
|
|
||||||
onEntityIdsChange: (values: string[]) => void;
|
|
||||||
entityTypeOptions: EntityTypeOption[];
|
|
||||||
geometryMetaForm: GeometryMetaFormState;
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||||
isEntitySubmitting: boolean;
|
isEntitySubmitting: boolean;
|
||||||
@@ -28,12 +23,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SelectedGeometryPanel({
|
export default function SelectedGeometryPanel({
|
||||||
selectedFeature,
|
selectedFeatures,
|
||||||
selectedFeatureEntitySummary,
|
|
||||||
selectedFeatureBindingSummary,
|
|
||||||
entities,
|
|
||||||
selectedGeometryEntityIds,
|
|
||||||
onEntityIdsChange,
|
|
||||||
entityTypeOptions,
|
entityTypeOptions,
|
||||||
geometryMetaForm,
|
geometryMetaForm,
|
||||||
onGeometryMetaFormChange,
|
onGeometryMetaFormChange,
|
||||||
@@ -78,15 +68,16 @@ export default function SelectedGeometryPanel({
|
|||||||
const visibleGeoApplyFeedback =
|
const visibleGeoApplyFeedback =
|
||||||
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
|
geoApplyFeedback && geoApplyFeedback.signature === geoMetaSignature ? geoApplyFeedback : null;
|
||||||
|
|
||||||
if (!selectedFeature) return null;
|
if (!selectedFeatures || selectedFeatures.length === 0) return null;
|
||||||
|
const representativeFeature = selectedFeatures[0];
|
||||||
|
|
||||||
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
|
const groupedGeometryTypeOptions = groupGeometryTypeOptions(entityTypeOptions);
|
||||||
const featureGeometryPreset = resolveFeatureGeometryPreset(selectedFeature);
|
const featureGeometryPreset = resolveFeatureGeometryPreset(representativeFeature);
|
||||||
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
const allowedGroupIds = getAllowedGroupIdsForPreset(featureGeometryPreset);
|
||||||
const groupedGeoTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
const groupedGeoTypeOptions = groupedGeometryTypeOptions.filter((group) =>
|
||||||
allowedGroupIds.includes(group.id)
|
allowedGroupIds.includes(group.id)
|
||||||
);
|
);
|
||||||
const selectedTypeOption = findEntityTypeOption(geometryMetaForm.type_key);
|
const selectedTypeOption = findGeometryTypeOption(geometryMetaForm.type_key);
|
||||||
const hasCurrentVisibleTypeOption = groupedGeoTypeOptions.some((group) =>
|
const hasCurrentVisibleTypeOption = groupedGeoTypeOptions.some((group) =>
|
||||||
group.options.some((option) => option.value === geometryMetaForm.type_key)
|
group.options.some((option) => option.value === geometryMetaForm.type_key)
|
||||||
);
|
);
|
||||||
@@ -102,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"
|
||||||
@@ -129,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: {String(selectedFeature.properties.id)}
|
|
||||||
</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",
|
||||||
@@ -305,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",
|
||||||
@@ -341,20 +261,20 @@ function MinusIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
|
function resolveFeatureGeometryPreset(feature: Feature): GeometryPreset {
|
||||||
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
|
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
|
||||||
if (explicitPreset) return explicitPreset;
|
if (explicitPreset) return explicitPreset;
|
||||||
|
|
||||||
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
||||||
if (semanticType) {
|
if (semanticType) {
|
||||||
const option = findEntityTypeOption(semanticType);
|
const option = findGeometryTypeOption(semanticType);
|
||||||
if (option) return option.geometryPreset;
|
if (option) return option.geometryPreset;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapGeometryTypeToPreset(feature.geometry.type);
|
return mapGeometryTypeToPreset(feature.geometry.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
|
function normalizeGeometryPreset(value: unknown): GeometryPreset | null {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
if (
|
if (
|
||||||
@@ -369,14 +289,12 @@ function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | 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(
|
||||||
geometryType: Feature["geometry"]["type"]
|
geometryType: Feature["geometry"]["type"]
|
||||||
): EntityGeometryPreset {
|
): GeometryPreset {
|
||||||
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||||
return "point";
|
return "point";
|
||||||
}
|
}
|
||||||
@@ -387,8 +305,8 @@ function mapGeometryTypeToPreset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAllowedGroupIdsForPreset(
|
function getAllowedGroupIdsForPreset(
|
||||||
geometryPreset: EntityGeometryPreset
|
geometryPreset: GeometryPreset
|
||||||
): EntityTypeGroupId[] {
|
): GeometryTypeGroupId[] {
|
||||||
if (geometryPreset === "point") {
|
if (geometryPreset === "point") {
|
||||||
return ["point"];
|
return ["point"];
|
||||||
}
|
}
|
||||||
@@ -403,11 +321,3 @@ function getAllowedGroupIdsForPreset(
|
|||||||
|
|
||||||
return ["polygon"];
|
return ["polygon"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatGeometryPresetLabel(preset: EntityGeometryPreset | 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";
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
type SubmitModalProps = {
|
||||||
|
isSubmitModalOpen: boolean;
|
||||||
|
submitContent: string;
|
||||||
|
setSubmitContent: (content: string) => void;
|
||||||
|
handleCancelSubmit: () => void;
|
||||||
|
handleConfirmSubmit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SubmitModal({
|
||||||
|
isSubmitModalOpen,
|
||||||
|
submitContent,
|
||||||
|
setSubmitContent,
|
||||||
|
handleCancelSubmit,
|
||||||
|
handleConfirmSubmit,
|
||||||
|
}: SubmitModalProps) {
|
||||||
|
if (!isSubmitModalOpen) return null;
|
||||||
|
|
||||||
|
const textAreaStyle = {
|
||||||
|
width: "100%",
|
||||||
|
marginTop: 8,
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#0b1220",
|
||||||
|
color: "white",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
fontSize: 13,
|
||||||
|
outline: "none",
|
||||||
|
resize: "vertical",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
height: 100,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: "#0b1220",
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
width: 400,
|
||||||
|
color: "white"
|
||||||
|
}}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Nội dung Submit</h3>
|
||||||
|
<textarea
|
||||||
|
value={submitContent}
|
||||||
|
onChange={(e) => setSubmitContent(e.target.value)}
|
||||||
|
placeholder="Nhập nội dung submit..."
|
||||||
|
style={textAreaStyle}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10, marginTop: 15 }}>
|
||||||
|
<button onClick={handleCancelSubmit} style={{ padding: "8px 16px", borderRadius: 6, cursor: "pointer", border: "1px solid #334155", background: "transparent", color: "white" }}>Hủy</button>
|
||||||
|
<button onClick={handleConfirmSubmit} style={{ padding: "8px 16px", borderRadius: 6, cursor: "pointer", border: "none", background: "#16a34a", color: "white", fontWeight: "bold" }}>Gửi Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import { Panel } from "./Panel";
|
||||||
|
import { ModeHint } from "./ModeHint";
|
||||||
|
|
||||||
|
type ToolsPanelProps = {
|
||||||
|
mode: EditorMode;
|
||||||
|
setMode: (mode: EditorMode) => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToolsPanel({ mode, setMode, onUndo }: ToolsPanelProps) {
|
||||||
|
const toggleMode = (newMode: EditorMode) => {
|
||||||
|
if (mode === newMode) {
|
||||||
|
setMode("idle");
|
||||||
|
} else {
|
||||||
|
setMode(newMode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeButtonStyle = (btnMode: EditorMode) =>
|
||||||
|
({
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: mode === btnMode ? "#16a34a" : "#111827",
|
||||||
|
color: "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 12,
|
||||||
|
minHeight: 34,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title="Tools" defaultOpen>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
|
<button style={modeButtonStyle("select")} onClick={() => toggleMode("select")} title="Select">
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
<button style={modeButtonStyle("draw")} onClick={() => toggleMode("draw")} title="Draw polygon">
|
||||||
|
Draw
|
||||||
|
</button>
|
||||||
|
<button style={modeButtonStyle("add-point")} onClick={() => setMode("add-point")} title="Add point">
|
||||||
|
Point
|
||||||
|
</button>
|
||||||
|
<button style={modeButtonStyle("add-line")} onClick={() => setMode("add-line")} title="Add line">
|
||||||
|
Line
|
||||||
|
</button>
|
||||||
|
<button style={modeButtonStyle("add-path")} onClick={() => setMode("add-path")} title="Add path">
|
||||||
|
Path
|
||||||
|
</button>
|
||||||
|
<button style={modeButtonStyle("add-circle")} onClick={() => setMode("add-circle")} title="Add circle">
|
||||||
|
Circle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10, fontSize: 12, color: "#94a3b8" }}>
|
||||||
|
Mode: <span style={{ color: "white", fontWeight: 850 }}>{mode}</span>
|
||||||
|
</div>
|
||||||
|
<ModeHint mode={mode} />
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...modeButtonStyle("idle"),
|
||||||
|
background: "#111827",
|
||||||
|
}}
|
||||||
|
onClick={() => setMode("idle")}
|
||||||
|
title="Tắt tool hiện tại"
|
||||||
|
>
|
||||||
|
Idle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...modeButtonStyle("idle"),
|
||||||
|
background: "#334155",
|
||||||
|
}}
|
||||||
|
onClick={onUndo}
|
||||||
|
title="Undo thao tác gần nhất"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
|
import { Panel } from "./Panel";
|
||||||
|
|
||||||
|
type UndoListPanelProps = {
|
||||||
|
undoStack: UndoAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UndoListPanel({ undoStack }: UndoListPanelProps) {
|
||||||
|
const recentUndoLabels = (() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const labels: string[] = [];
|
||||||
|
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
|
||||||
|
const label = formatUndoLabel(undoStack[i]);
|
||||||
|
if (seen.has(label)) continue;
|
||||||
|
seen.add(label);
|
||||||
|
labels.push(label);
|
||||||
|
}
|
||||||
|
return labels.reverse();
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title="Undo List" badge={String(recentUndoLabels.length)} defaultOpen={false}>
|
||||||
|
{recentUndoLabels.length === 0 ? (
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: 13 }}>Chưa có thao tác</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 13, color: "#e2e8f0" }}>
|
||||||
|
{recentUndoLabels.map((label, idx) => (
|
||||||
|
<li key={`${label}-${idx}`} style={{ padding: "6px 0", borderBottom: "1px solid #1f2937" }}>
|
||||||
|
{label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUndoLabel(action: UndoAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case "create":
|
||||||
|
return `Thêm mới #${action.id}`;
|
||||||
|
case "delete":
|
||||||
|
return `Xóa #${action.feature.properties.id}`;
|
||||||
|
case "update":
|
||||||
|
return `Chỉnh sửa #${action.id}`;
|
||||||
|
case "properties":
|
||||||
|
return `Cập nhật thuộc tính #${action.id}`;
|
||||||
|
case "snapshot_entities":
|
||||||
|
case "snapshot_wikis":
|
||||||
|
case "snapshot_entity_wiki":
|
||||||
|
case "group":
|
||||||
|
return action.label;
|
||||||
|
default:
|
||||||
|
return "Tác vụ";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,812 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import polylabel from "polylabel";
|
||||||
|
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
|
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
|
import {
|
||||||
|
FEATURE_STATE_SOURCE_IDS,
|
||||||
|
PATH_ARROW_ICON_ID,
|
||||||
|
RASTER_BASE_INSERT_BEFORE_LAYER_ID,
|
||||||
|
RASTER_BASE_LAYER_ID,
|
||||||
|
RASTER_BASE_SOURCE_ID,
|
||||||
|
PATH_ARROW_SOURCE_ID
|
||||||
|
} from "@/uhm/lib/map/constants";
|
||||||
|
import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/styles/style";
|
||||||
|
import { getRasterTileTemplateUrl } from "@/uhm/api/tiles";
|
||||||
|
import { newId } from "@/uhm/lib/utils/id";
|
||||||
|
import { normalizeGeoTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||||
|
|
||||||
|
type Coordinate = [number, number];
|
||||||
|
type PolygonCoordinates = Coordinate[][];
|
||||||
|
type FeatureLabelInfo = {
|
||||||
|
entityId: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applyBackgroundLayerVisibility(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
visibility: BackgroundLayerVisibility
|
||||||
|
) {
|
||||||
|
syncRasterBaseVisibility(map, visibility[RASTER_BASE_LAYER_ID]);
|
||||||
|
|
||||||
|
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||||
|
if (layer.id === RASTER_BASE_LAYER_ID) continue;
|
||||||
|
if (!map.getLayer(layer.id)) continue;
|
||||||
|
map.setLayoutProperty(
|
||||||
|
layer.id,
|
||||||
|
"visibility",
|
||||||
|
visibility[layer.id] ? "visible" : "none"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
|
||||||
|
if (shouldShow) {
|
||||||
|
ensureRasterBaseLayer(map);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeRasterBaseLayer(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureRasterBaseLayer(map: maplibregl.Map) {
|
||||||
|
if (!map.getSource(RASTER_BASE_SOURCE_ID)) {
|
||||||
|
map.addSource(RASTER_BASE_SOURCE_ID, createRasterBaseSource());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(RASTER_BASE_LAYER_ID)) {
|
||||||
|
const beforeId = map.getLayer(RASTER_BASE_INSERT_BEFORE_LAYER_ID)
|
||||||
|
? RASTER_BASE_INSERT_BEFORE_LAYER_ID
|
||||||
|
: undefined;
|
||||||
|
map.addLayer(createRasterBaseLayer(), beforeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeRasterBaseLayer(map: maplibregl.Map) {
|
||||||
|
if (map.getLayer(RASTER_BASE_LAYER_ID)) {
|
||||||
|
map.removeLayer(RASTER_BASE_LAYER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.getSource(RASTER_BASE_SOURCE_ID)) {
|
||||||
|
map.removeSource(RASTER_BASE_SOURCE_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRasterBaseSource() {
|
||||||
|
return {
|
||||||
|
type: "raster" as const,
|
||||||
|
tiles: [getRasterTileTemplateUrl()],
|
||||||
|
tileSize: 256,
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 6,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRasterBaseLayer() {
|
||||||
|
return {
|
||||||
|
id: RASTER_BASE_LAYER_ID,
|
||||||
|
type: "raster" as const,
|
||||||
|
source: RASTER_BASE_SOURCE_ID,
|
||||||
|
paint: {
|
||||||
|
"raster-opacity": 0.92,
|
||||||
|
"raster-resampling": "linear" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectableLayers(map: maplibregl.Map): string[] {
|
||||||
|
const selectableSources = ["countries", "places", PATH_ARROW_SOURCE_ID];
|
||||||
|
const style = map.getStyle();
|
||||||
|
if (!style || !style.layers) return [];
|
||||||
|
|
||||||
|
return style.layers
|
||||||
|
.filter((layer) => "source" in layer && selectableSources.includes(layer.source as string))
|
||||||
|
.map((layer) => layer.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterDraftByBinding(
|
||||||
|
fc: FeatureCollection,
|
||||||
|
selectedFeatureIds: (string | number)[],
|
||||||
|
highlightFeatures?: FeatureCollection | null
|
||||||
|
): FeatureCollection {
|
||||||
|
const selectedIds = new Set(selectedFeatureIds.map(String));
|
||||||
|
if (highlightFeatures?.features) {
|
||||||
|
for (const f of highlightFeatures.features) {
|
||||||
|
if (f.properties?.id != null) selectedIds.add(String(f.properties.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const childIds = new Set<string>();
|
||||||
|
for (const feature of fc.features) {
|
||||||
|
for (const id of normalizeBindingIds(feature.properties.binding)) {
|
||||||
|
childIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedChildren = new Set<string>();
|
||||||
|
for (const feature of fc.features) {
|
||||||
|
if (selectedIds.has(String(feature.properties.id))) {
|
||||||
|
for (const id of normalizeBindingIds(feature.properties.binding)) {
|
||||||
|
selectedChildren.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fc,
|
||||||
|
features: fc.features.filter((feature) => {
|
||||||
|
const featureId = String(feature.properties.id);
|
||||||
|
if (selectedIds.has(featureId)) return true;
|
||||||
|
if (selectedChildren.has(featureId)) return true;
|
||||||
|
return !childIds.has(featureId);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterDraftByGeometryVisibility(
|
||||||
|
fc: FeatureCollection,
|
||||||
|
visibility: Record<string, boolean> | null | undefined
|
||||||
|
): FeatureCollection {
|
||||||
|
if (!visibility) return fc;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fc,
|
||||||
|
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);
|
||||||
|
if (!key) return true;
|
||||||
|
// Kiểm tra ẩn theo loại (semantic type)
|
||||||
|
return visibility[key] !== false;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBindingIds(rawBinding: unknown): string[] {
|
||||||
|
if (!Array.isArray(rawBinding)) return [];
|
||||||
|
const deduped: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const rawId of rawBinding) {
|
||||||
|
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
|
||||||
|
const id = String(rawId).trim();
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
deduped.push(id);
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitDraftFeatures(fc: FeatureCollection) {
|
||||||
|
const polygons = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: fc.features.filter((f) =>
|
||||||
|
f.geometry.type !== "Point" && f.geometry.type !== "MultiPoint"
|
||||||
|
),
|
||||||
|
} as FeatureCollection;
|
||||||
|
|
||||||
|
const points = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: fc.features.filter((f) =>
|
||||||
|
f.geometry.type === "Point" || f.geometry.type === "MultiPoint"
|
||||||
|
),
|
||||||
|
} as FeatureCollection;
|
||||||
|
|
||||||
|
return { polygons, points };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decoratePointFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||||
|
const getLabel = createFeatureLabelResolver(labelContext);
|
||||||
|
return {
|
||||||
|
...fc,
|
||||||
|
features: fc.features.map((feature) => ({
|
||||||
|
...feature,
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
point_label: getLabel(feature),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decorateLineFeaturesWithLabels(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||||
|
const getLabel = createFeatureLabelResolver(labelContext);
|
||||||
|
return {
|
||||||
|
...fc,
|
||||||
|
features: fc.features.map((feature) => ({
|
||||||
|
...feature,
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
line_label: isLineGeometry(feature.geometry) ? getLabel(feature) : null,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPolygonLabelFeatureCollection(fc: FeatureCollection, labelContext: FeatureCollection = fc): FeatureCollection {
|
||||||
|
const getLabel = createFeatureLabelResolver(labelContext);
|
||||||
|
const features: Feature[] = [];
|
||||||
|
|
||||||
|
for (const feature of fc.features) {
|
||||||
|
const label = getLabel(feature);
|
||||||
|
if (!label) continue;
|
||||||
|
|
||||||
|
const labelPoint = getPolygonLabelPoint(feature.geometry);
|
||||||
|
if (!labelPoint) continue;
|
||||||
|
|
||||||
|
features.push({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
id: `${feature.properties.id}:polygon-label`,
|
||||||
|
polygon_label: label,
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: labelPoint,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "FeatureCollection", features };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSelectedFeatureState(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
id: string | number | null,
|
||||||
|
selected: boolean
|
||||||
|
) {
|
||||||
|
if (id === null) return;
|
||||||
|
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
|
||||||
|
if (!map.getSource(sourceId)) continue;
|
||||||
|
map.setFeatureState({ source: sourceId, id }, { selected });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fitMapToFeatureCollection(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
fc: FeatureCollection,
|
||||||
|
padding?: number | maplibregl.PaddingOptions,
|
||||||
|
options?: {
|
||||||
|
duration?: number;
|
||||||
|
maxZoom?: number;
|
||||||
|
pointZoom?: number;
|
||||||
|
}
|
||||||
|
): boolean {
|
||||||
|
const bbox = getFeatureCollectionBBox(fc);
|
||||||
|
if (!bbox) return false;
|
||||||
|
|
||||||
|
const resolvedPadding = typeof padding === "number" || padding ? padding : 58;
|
||||||
|
const duration = options?.duration ?? 0;
|
||||||
|
const maxZoom = options?.maxZoom ?? 7;
|
||||||
|
const pointZoom = options?.pointZoom ?? 6;
|
||||||
|
|
||||||
|
const lngSpan = Math.abs(bbox.maxLng - bbox.minLng);
|
||||||
|
const latSpan = Math.abs(bbox.maxLat - bbox.minLat);
|
||||||
|
if (lngSpan < 0.000001 && latSpan < 0.000001) {
|
||||||
|
map.easeTo({
|
||||||
|
center: [bbox.minLng, bbox.minLat],
|
||||||
|
zoom: pointZoom,
|
||||||
|
padding: resolvedPadding,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
map.fitBounds(
|
||||||
|
[
|
||||||
|
[bbox.minLng, bbox.minLat],
|
||||||
|
[bbox.maxLng, bbox.maxLat],
|
||||||
|
],
|
||||||
|
{
|
||||||
|
padding: resolvedPadding,
|
||||||
|
maxZoom,
|
||||||
|
duration,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeatureCollectionBBox(
|
||||||
|
fc: FeatureCollection
|
||||||
|
): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
|
||||||
|
const points = fc.features.flatMap((feature) => collectCoordinatePairs(feature.geometry.coordinates));
|
||||||
|
if (!points.length) return null;
|
||||||
|
|
||||||
|
let minLng = Number.POSITIVE_INFINITY;
|
||||||
|
let minLat = Number.POSITIVE_INFINITY;
|
||||||
|
let maxLng = Number.NEGATIVE_INFINITY;
|
||||||
|
let maxLat = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const [lng, lat] of points) {
|
||||||
|
minLng = Math.min(minLng, lng);
|
||||||
|
minLat = Math.min(minLat, lat);
|
||||||
|
maxLng = Math.max(maxLng, lng);
|
||||||
|
maxLat = Math.max(maxLat, lat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { minLng, minLat, maxLng, maxLat };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectCoordinatePairs(value: unknown): Array<[number, number]> {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
if (
|
||||||
|
value.length >= 2 &&
|
||||||
|
typeof value[0] === "number" &&
|
||||||
|
typeof value[1] === "number" &&
|
||||||
|
Number.isFinite(value[0]) &&
|
||||||
|
Number.isFinite(value[1])
|
||||||
|
) {
|
||||||
|
return [[value[0], value[1]]];
|
||||||
|
}
|
||||||
|
return value.flatMap((item) => collectCoordinatePairs(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
|
||||||
|
const features: Feature[] = [];
|
||||||
|
|
||||||
|
for (const feature of fc.features) {
|
||||||
|
if (!isPathFeature(feature)) continue;
|
||||||
|
|
||||||
|
const coordinateGroups = getLineCoordinateGroups(feature.geometry);
|
||||||
|
for (const coordinates of coordinateGroups) {
|
||||||
|
const geometry = buildPathArrowGeometry(coordinates);
|
||||||
|
if (!geometry) continue;
|
||||||
|
features.push({
|
||||||
|
type: "Feature",
|
||||||
|
properties: { ...feature.properties },
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPathFeature(feature: Feature): boolean {
|
||||||
|
const featureType = getFeatureSemanticType(feature);
|
||||||
|
return Boolean(featureType && PATH_RENDER_BY_TYPE[featureType]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeatureSemanticType(feature: Feature): string | null {
|
||||||
|
const value = feature.properties.type || feature.properties.entity_type_id || null;
|
||||||
|
return normalizeGeoTypeKey(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPathArrowGeometry(coords: [number, number][]): Geometry | null {
|
||||||
|
const sourceCoords = removeDuplicatePathCoords(coords);
|
||||||
|
if (sourceCoords.length < 2) return null;
|
||||||
|
|
||||||
|
const origin = sourceCoords[0];
|
||||||
|
const originLatRad = toRadians(origin[1]);
|
||||||
|
const cosOriginLat = Math.max(Math.cos(originLatRad), 0.000001);
|
||||||
|
const projected = sourceCoords.map((coord) => projectLngLat(coord, origin, cosOriginLat));
|
||||||
|
const measured = buildMeasuredPath(projected);
|
||||||
|
const totalLength = measured[measured.length - 1]?.distance || 0;
|
||||||
|
if (totalLength <= 0) return null;
|
||||||
|
|
||||||
|
const headLength = clampNumber(totalLength * 0.24, totalLength * 0.12, totalLength * 0.45);
|
||||||
|
const bodyEndDistance = Math.max(totalLength - headLength, totalLength * 0.35);
|
||||||
|
const bodyPoints = measured
|
||||||
|
.filter((point) => point.distance < bodyEndDistance)
|
||||||
|
.map(({ x, y, distance }) => ({ x, y, distance }));
|
||||||
|
bodyPoints.push(pointAtDistance(measured, bodyEndDistance));
|
||||||
|
|
||||||
|
if (bodyPoints.length < 2) return null;
|
||||||
|
|
||||||
|
const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000);
|
||||||
|
const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000);
|
||||||
|
const headWidth = shoulderWidth * 2.0;
|
||||||
|
|
||||||
|
const leftBody: ProjectedPoint[] = [];
|
||||||
|
const rightBody: ProjectedPoint[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < bodyPoints.length; i += 1) {
|
||||||
|
const point = bodyPoints[i];
|
||||||
|
const normal = normalAt(bodyPoints, i);
|
||||||
|
const progress = bodyEndDistance > 0
|
||||||
|
? Math.pow(clampNumber(point.distance / bodyEndDistance, 0, 1), 0.9)
|
||||||
|
: 0;
|
||||||
|
const width = tailWidth + (shoulderWidth - tailWidth) * progress;
|
||||||
|
const half = width / 2;
|
||||||
|
leftBody.push({
|
||||||
|
x: point.x + normal.x * half,
|
||||||
|
y: point.y + normal.y * half,
|
||||||
|
});
|
||||||
|
rightBody.push({
|
||||||
|
x: point.x - normal.x * half,
|
||||||
|
y: point.y - normal.y * half,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = bodyPoints[bodyPoints.length - 1];
|
||||||
|
const tip = pointAtDistance(measured, totalLength);
|
||||||
|
const headNormal = normalFromSegment(base, tip) || normalAt(bodyPoints, bodyPoints.length - 1);
|
||||||
|
const headHalf = headWidth / 2;
|
||||||
|
const headBaseLeft = {
|
||||||
|
x: base.x + headNormal.x * headHalf,
|
||||||
|
y: base.y + headNormal.y * headHalf,
|
||||||
|
};
|
||||||
|
const headBaseRight = {
|
||||||
|
x: base.x - headNormal.x * headHalf,
|
||||||
|
y: base.y - headNormal.y * headHalf,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ring = [
|
||||||
|
...leftBody,
|
||||||
|
headBaseLeft,
|
||||||
|
{ x: tip.x, y: tip.y },
|
||||||
|
headBaseRight,
|
||||||
|
...rightBody.reverse(),
|
||||||
|
leftBody[0],
|
||||||
|
].map((point) => unprojectLngLat(point, origin, cosOriginLat));
|
||||||
|
|
||||||
|
if (ring.length < 4) return null;
|
||||||
|
return {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [ring],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectedPoint = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MeasuredPoint = ProjectedPoint & {
|
||||||
|
distance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function removeDuplicatePathCoords(coords: [number, number][]): [number, number][] {
|
||||||
|
const result: [number, number][] = [];
|
||||||
|
for (const coord of coords) {
|
||||||
|
const last = result[result.length - 1];
|
||||||
|
if (last && last[0] === coord[0] && last[1] === coord[1]) continue;
|
||||||
|
result.push(coord);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectLngLat(
|
||||||
|
coord: [number, number],
|
||||||
|
origin: [number, number],
|
||||||
|
cosOriginLat: number
|
||||||
|
): ProjectedPoint {
|
||||||
|
const earthRadiusMeters = 6371008.8;
|
||||||
|
return {
|
||||||
|
x: toRadians(coord[0] - origin[0]) * earthRadiusMeters * cosOriginLat,
|
||||||
|
y: toRadians(coord[1] - origin[1]) * earthRadiusMeters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unprojectLngLat(
|
||||||
|
point: ProjectedPoint,
|
||||||
|
origin: [number, number],
|
||||||
|
cosOriginLat: number
|
||||||
|
): [number, number] {
|
||||||
|
const earthRadiusMeters = 6371008.8;
|
||||||
|
return [
|
||||||
|
origin[0] + toDegrees(point.x / (earthRadiusMeters * cosOriginLat)),
|
||||||
|
origin[1] + toDegrees(point.y / earthRadiusMeters),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMeasuredPath(points: ProjectedPoint[]): MeasuredPoint[] {
|
||||||
|
let distance = 0;
|
||||||
|
return points.map((point, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
distance += distanceProjected(points[index - 1], point);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...point,
|
||||||
|
distance,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointAtDistance(points: MeasuredPoint[], targetDistance: number): MeasuredPoint {
|
||||||
|
if (targetDistance <= 0) return points[0];
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
const prev = points[i - 1];
|
||||||
|
const next = points[i];
|
||||||
|
if (targetDistance > next.distance) continue;
|
||||||
|
const segmentLength = next.distance - prev.distance;
|
||||||
|
const t = segmentLength > 0 ? (targetDistance - prev.distance) / segmentLength : 0;
|
||||||
|
return {
|
||||||
|
x: prev.x + (next.x - prev.x) * t,
|
||||||
|
y: prev.y + (next.y - prev.y) * t,
|
||||||
|
distance: targetDistance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return points[points.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalAt(points: ProjectedPoint[], index: number): ProjectedPoint {
|
||||||
|
const prev = points[Math.max(0, index - 1)];
|
||||||
|
const next = points[Math.min(points.length - 1, index + 1)];
|
||||||
|
return normalFromSegment(prev, next) || { x: 0, y: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalFromSegment(a: ProjectedPoint, b: ProjectedPoint): ProjectedPoint | null {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const length = Math.hypot(dx, dy);
|
||||||
|
if (length <= 0) return null;
|
||||||
|
return {
|
||||||
|
x: -dy / length,
|
||||||
|
y: dx / length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distanceProjected(a: ProjectedPoint, b: ProjectedPoint): number {
|
||||||
|
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toRadians(value: number): number {
|
||||||
|
return (value * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDegrees(value: number): number {
|
||||||
|
return (value * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensurePathArrowIcon(map: maplibregl.Map): boolean {
|
||||||
|
if (map.hasImage(PATH_ARROW_ICON_ID)) return true;
|
||||||
|
const imageData = createPathArrowImageData();
|
||||||
|
if (!imageData) return false;
|
||||||
|
map.addImage(PATH_ARROW_ICON_ID, imageData, { pixelRatio: 2 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPathArrowImageData(): ImageData | null {
|
||||||
|
const size = 56;
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
ctx.strokeStyle = "#0f172a";
|
||||||
|
ctx.fillStyle = "#38bdf8";
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(8, 16);
|
||||||
|
ctx.lineTo(28, 16);
|
||||||
|
ctx.lineTo(28, 10);
|
||||||
|
ctx.lineTo(46, 28);
|
||||||
|
ctx.lineTo(28, 46);
|
||||||
|
ctx.lineTo(28, 40);
|
||||||
|
ctx.lineTo(8, 40);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
return ctx.getImageData(0, 0, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTypeMatchExpression(
|
||||||
|
valueByType: Record<string, string | number | boolean>,
|
||||||
|
fallback: string | number | boolean
|
||||||
|
): maplibregl.ExpressionSpecification {
|
||||||
|
const expression: unknown[] = ["match", getFeatureTypeExpression()];
|
||||||
|
|
||||||
|
for (const [typeId, value] of Object.entries(valueByType)) {
|
||||||
|
expression.push(typeId, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
expression.push(fallback);
|
||||||
|
return expression as maplibregl.ExpressionSpecification;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeatureTypeExpression(): maplibregl.ExpressionSpecification {
|
||||||
|
return [
|
||||||
|
"coalesce",
|
||||||
|
["get", "type"],
|
||||||
|
["get", "entity_type_id"],
|
||||||
|
"",
|
||||||
|
] as maplibregl.ExpressionSpecification;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundZoom(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFeatureLabelResolver(fc: FeatureCollection): (feature: Feature) => string | null {
|
||||||
|
const directLabelsByFeatureId = new Map<string, FeatureLabelInfo>();
|
||||||
|
const inheritedLabelsByChildId = new Map<string, FeatureLabelInfo | null>();
|
||||||
|
|
||||||
|
for (const feature of fc.features) {
|
||||||
|
const labelInfo = getSingleEntityFeatureLabelInfo(feature);
|
||||||
|
if (!labelInfo) continue;
|
||||||
|
directLabelsByFeatureId.set(String(feature.properties.id), labelInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feature of fc.features) {
|
||||||
|
const parentLabel = directLabelsByFeatureId.get(String(feature.properties.id));
|
||||||
|
const featureId = String(feature.properties.id);
|
||||||
|
const bindingIds = normalizeBindingIds(feature.properties.binding);
|
||||||
|
|
||||||
|
if (parentLabel) {
|
||||||
|
for (const childId of bindingIds) {
|
||||||
|
mergeInheritedFeatureLabel(inheritedLabelsByChildId, childId, parentLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const parentId of bindingIds) {
|
||||||
|
const linkedParentLabel = directLabelsByFeatureId.get(parentId);
|
||||||
|
if (linkedParentLabel) {
|
||||||
|
mergeInheritedFeatureLabel(inheritedLabelsByChildId, featureId, linkedParentLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (feature) => {
|
||||||
|
const featureId = String(feature.properties.id);
|
||||||
|
const directEntityIds = getFeatureEntityIds(feature);
|
||||||
|
if (directEntityIds.length > 0) {
|
||||||
|
return directLabelsByFeatureId.get(featureId)?.label || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inheritedLabelsByChildId.get(featureId)?.label || null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeInheritedFeatureLabel(
|
||||||
|
labelsByFeatureId: Map<string, FeatureLabelInfo | null>,
|
||||||
|
targetFeatureId: string,
|
||||||
|
labelInfo: FeatureLabelInfo
|
||||||
|
) {
|
||||||
|
const current = labelsByFeatureId.get(targetFeatureId);
|
||||||
|
if (current === undefined) {
|
||||||
|
labelsByFeatureId.set(targetFeatureId, labelInfo);
|
||||||
|
} else if (current && current.entityId === labelInfo.entityId) {
|
||||||
|
labelsByFeatureId.set(targetFeatureId, current);
|
||||||
|
} else {
|
||||||
|
labelsByFeatureId.set(targetFeatureId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSingleEntityFeatureLabelInfo(feature: Feature): FeatureLabelInfo | null {
|
||||||
|
const entityIds = getFeatureEntityIds(feature);
|
||||||
|
if (entityIds.length !== 1) return null;
|
||||||
|
|
||||||
|
const label = getSingleEntityName(feature);
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return { entityId: entityIds[0], label };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFeatureEntityIds(feature: Feature): string[] {
|
||||||
|
const rawEntityIds: unknown[] = Array.isArray(feature.properties.entity_ids)
|
||||||
|
? feature.properties.entity_ids
|
||||||
|
: (typeof feature.properties.entity_id === "string" || typeof feature.properties.entity_id === "number"
|
||||||
|
? [feature.properties.entity_id]
|
||||||
|
: []);
|
||||||
|
|
||||||
|
return Array.from(new Set(
|
||||||
|
rawEntityIds
|
||||||
|
.filter((id): id is string | number => typeof id === "string" || typeof id === "number")
|
||||||
|
.map((id) => String(id).trim())
|
||||||
|
.filter((id) => id.length > 0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSingleEntityName(feature: Feature): string | null {
|
||||||
|
const directName = typeof feature.properties.entity_name === "string"
|
||||||
|
? feature.properties.entity_name.trim()
|
||||||
|
: "";
|
||||||
|
if (directName.length > 0) return directName;
|
||||||
|
|
||||||
|
const names = Array.isArray(feature.properties.entity_names)
|
||||||
|
? Array.from(new Set(
|
||||||
|
feature.properties.entity_names
|
||||||
|
.filter((name): name is string => typeof name === "string")
|
||||||
|
.map((name) => name.trim())
|
||||||
|
.filter((name) => name.length > 0)
|
||||||
|
))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return names.length === 1 ? names[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLineGeometry(geometry: Geometry): boolean {
|
||||||
|
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 {
|
||||||
|
if (geometry.type === "Polygon") {
|
||||||
|
return getPolygonLabelCandidate(geometry.coordinates)?.point || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometry.type === "MultiPolygon") {
|
||||||
|
let best: { point: Coordinate; distance: number } | null = null;
|
||||||
|
for (const polygon of geometry.coordinates) {
|
||||||
|
const candidate = getPolygonLabelCandidate(polygon);
|
||||||
|
if (!candidate) continue;
|
||||||
|
if (!best || candidate.distance > best.distance) {
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best?.point || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPolygonLabelCandidate(polygon: PolygonCoordinates): { point: Coordinate; distance: number } | null {
|
||||||
|
const outerRing = polygon[0];
|
||||||
|
if (!outerRing || outerRing.length < 3) return null;
|
||||||
|
|
||||||
|
const bbox = getRingBbox(outerRing);
|
||||||
|
if (!bbox) return null;
|
||||||
|
|
||||||
|
const width = bbox.maxX - bbox.minX;
|
||||||
|
const height = bbox.maxY - bbox.minY;
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
const fallback: Coordinate = [bbox.minX, bbox.minY];
|
||||||
|
return { point: fallback, distance: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const precision = Math.max(Math.max(width, height) / 100, 0.0001);
|
||||||
|
const result = polylabel(polygon, precision);
|
||||||
|
const x = result[0];
|
||||||
|
const y = result[1];
|
||||||
|
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||||
|
return { point: [bbox.minX + width / 2, bbox.minY + height / 2], distance: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { point: [x, y], distance: Number.isFinite(result.distance) ? result.distance : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRingBbox(ring: Coordinate[]): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
||||||
|
if (!ring.length) return null;
|
||||||
|
|
||||||
|
let minX = Number.POSITIVE_INFINITY;
|
||||||
|
let minY = Number.POSITIVE_INFINITY;
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY;
|
||||||
|
let maxY = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const [x, y] of ring) {
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x);
|
||||||
|
maxY = Math.max(maxY, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClientFeatureId(): string {
|
||||||
|
return newId();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampNumber(value: number, min: number, max: number): number {
|
||||||
|
if (value < min) return min;
|
||||||
|
if (value > max) return max;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "@/uhm/lib/map/constants";
|
||||||
|
import { clampNumber, roundZoom } from "./mapUtils";
|
||||||
|
import { getBaseMapStyle } from "./useMapLayers";
|
||||||
|
|
||||||
|
const MAP_PROJECTION_STORAGE_KEY = "uhm:mapProjection";
|
||||||
|
|
||||||
|
export function applyMapProjection(map: maplibregl.Map, isGlobe: boolean) {
|
||||||
|
map.setProjection({ type: isGlobe ? "globe" : "mercator" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMapInstance() {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
const [fatalInitError, setFatalInitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(2);
|
||||||
|
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||||
|
|
||||||
|
const [isGlobeProjection, setIsGlobeProjection] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(MAP_PROJECTION_STORAGE_KEY) === "globe";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isMapLoaded, setIsMapLoaded] = useState(false);
|
||||||
|
const geolocationCenteredRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
MAP_PROJECTION_STORAGE_KEY,
|
||||||
|
isGlobeProjection ? "globe" : "mercator"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [isGlobeProjection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container,
|
||||||
|
attributionControl: false,
|
||||||
|
minZoom: MAP_MIN_ZOOM,
|
||||||
|
maxZoom: MAP_MAX_ZOOM,
|
||||||
|
style: getBaseMapStyle(),
|
||||||
|
center: [0, 20],
|
||||||
|
zoom: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
const syncZoomLevel = () => {
|
||||||
|
setZoomLevel(roundZoom(map.getZoom()));
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("load", () => {
|
||||||
|
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||||
|
syncZoomLevel();
|
||||||
|
map.on("zoom", syncZoomLevel);
|
||||||
|
setIsMapLoaded(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("zoom", syncZoomLevel);
|
||||||
|
setIsMapLoaded(false);
|
||||||
|
if (mapRef.current === map) {
|
||||||
|
mapRef.current = null;
|
||||||
|
}
|
||||||
|
map.remove();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Map initialization failed", err);
|
||||||
|
setFatalInitError(err instanceof Error ? err.message : "Map initialization failed.");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync Map Projection
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const apply = () => {
|
||||||
|
if (mapRef.current !== map) return;
|
||||||
|
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
|
||||||
|
applyMapProjection(map, isGlobeProjection);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof map.isStyleLoaded === "function" && map.isStyleLoaded()) {
|
||||||
|
apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
map.once("load", apply);
|
||||||
|
map.once("style.load", apply);
|
||||||
|
return () => {
|
||||||
|
map.off("load", apply);
|
||||||
|
map.off("style.load", apply);
|
||||||
|
};
|
||||||
|
}, [isGlobeProjection]);
|
||||||
|
|
||||||
|
const handleZoomByStep = useCallback((delta: number) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
setZoomLevel((prev) => {
|
||||||
|
const next = clampNumber(prev + delta, zoomBounds.min, zoomBounds.max);
|
||||||
|
map.easeTo({ zoom: next, duration: 120 });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [zoomBounds]);
|
||||||
|
|
||||||
|
const handleZoomSliderChange = useCallback((nextRaw: number) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !Number.isFinite(nextRaw)) return;
|
||||||
|
const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max);
|
||||||
|
map.easeTo({ zoom: next, duration: 80 });
|
||||||
|
setZoomLevel(next);
|
||||||
|
}, [zoomBounds]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapRef,
|
||||||
|
containerRef,
|
||||||
|
fatalInitError,
|
||||||
|
setFatalInitError,
|
||||||
|
zoomLevel,
|
||||||
|
zoomBounds,
|
||||||
|
isGlobeProjection,
|
||||||
|
setIsGlobeProjection,
|
||||||
|
isMapLoaded,
|
||||||
|
geolocationCenteredRef,
|
||||||
|
handleZoomByStep,
|
||||||
|
handleZoomSliderChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { initDrawing } from "@/uhm/lib/map/engines/drawingEngine";
|
||||||
|
import { initSelect } from "@/uhm/lib/map/engines/selectingEngine";
|
||||||
|
import { initPoint } from "@/uhm/lib/map/engines/pointEngine";
|
||||||
|
import { initLine } from "@/uhm/lib/map/engines/lineEngine";
|
||||||
|
import { initPath } from "@/uhm/lib/map/engines/pathEngine";
|
||||||
|
import { initCircle } from "@/uhm/lib/map/engines/circleEngine";
|
||||||
|
import { createEditingEngine } from "@/uhm/lib/map/engines/editingEngine";
|
||||||
|
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
|
import { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import { buildClientFeatureId, getSelectableLayers } from "./mapUtils";
|
||||||
|
import { MapHoverPayload } from "../Map";
|
||||||
|
|
||||||
|
type EngineBinding = {
|
||||||
|
cleanup: () => void;
|
||||||
|
cancel?: () => void;
|
||||||
|
clearSelection?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseMapInteractionProps = {
|
||||||
|
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||||
|
mode: EditorMode;
|
||||||
|
modeRef: React.MutableRefObject<EditorMode>;
|
||||||
|
draftRef: React.MutableRefObject<FeatureCollection>;
|
||||||
|
allowGeometryEditing: boolean;
|
||||||
|
selectedFeatureIds: (string | number)[];
|
||||||
|
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
|
||||||
|
onSetModeRef: React.MutableRefObject<((mode: EditorMode) => void) | undefined>;
|
||||||
|
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
|
||||||
|
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
|
||||||
|
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
|
||||||
|
onHoverFeatureChangeRef: React.MutableRefObject<((payload: MapHoverPayload | null) => void) | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMapInteraction({
|
||||||
|
mapRef,
|
||||||
|
mode,
|
||||||
|
modeRef,
|
||||||
|
draftRef,
|
||||||
|
allowGeometryEditing,
|
||||||
|
selectedFeatureIds,
|
||||||
|
onSelectFeatureIdsRef,
|
||||||
|
onSetModeRef,
|
||||||
|
onCreateRef,
|
||||||
|
onDeleteRef,
|
||||||
|
onUpdateRef,
|
||||||
|
onHoverFeatureChangeRef,
|
||||||
|
}: UseMapInteractionProps) {
|
||||||
|
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||||
|
const engineBindingsRef = useRef<Partial<Record<EditorMode, EngineBinding>>>({});
|
||||||
|
const previousModeRef = useRef<EditorMode>(mode);
|
||||||
|
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingEngineRef.current) {
|
||||||
|
editingEngineRef.current = createEditingEngine({
|
||||||
|
mapRef,
|
||||||
|
onUpdate: (id, geometry) => onUpdateRef.current?.(id, geometry),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mapRef, onUpdateRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
||||||
|
editingEngineRef.current?.clearEditing();
|
||||||
|
}
|
||||||
|
}, [mode, selectedFeatureIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousMode = previousModeRef.current;
|
||||||
|
if (previousMode !== mode) {
|
||||||
|
engineBindingsRef.current[previousMode]?.cancel?.();
|
||||||
|
previousModeRef.current = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
if (mode !== "draw") {
|
||||||
|
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (mode !== "add-line") {
|
||||||
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (mode !== "add-path") {
|
||||||
|
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (mode !== "add-circle") {
|
||||||
|
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mode, mapRef]);
|
||||||
|
|
||||||
|
const setupMapInteractions = (map: maplibregl.Map) => {
|
||||||
|
const drawingEngine = initDrawing(
|
||||||
|
map,
|
||||||
|
() => modeRef.current,
|
||||||
|
(geometry: Geometry) => {
|
||||||
|
const id = buildClientFeatureId();
|
||||||
|
onCreateRef.current?.({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
id,
|
||||||
|
type: "country",
|
||||||
|
geometry_preset: "polygon",
|
||||||
|
entity_id: null,
|
||||||
|
entity_ids: [],
|
||||||
|
entity_name: null,
|
||||||
|
entity_type_id: null,
|
||||||
|
binding: [],
|
||||||
|
},
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectEngine = initSelect(
|
||||||
|
map,
|
||||||
|
() => modeRef.current,
|
||||||
|
allowGeometryEditing
|
||||||
|
? (id: string | number) => {
|
||||||
|
editingEngineRef.current?.clearEditing();
|
||||||
|
onSelectFeatureIdsRef.current?.([]);
|
||||||
|
onDeleteRef.current?.(id);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
allowGeometryEditing
|
||||||
|
? (feature) => {
|
||||||
|
const rawId = feature.id ?? feature.properties?.id;
|
||||||
|
const originalFeature = draftRef.current.features.find(
|
||||||
|
(item) => String(item.properties.id) === String(rawId)
|
||||||
|
);
|
||||||
|
editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
(ids) => onSelectFeatureIdsRef.current?.(ids),
|
||||||
|
(id: string | number) => onSetModeRef.current?.("replay", id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanupPoint = initPoint(
|
||||||
|
map,
|
||||||
|
() => modeRef.current,
|
||||||
|
(geometry: Geometry) => {
|
||||||
|
const id = buildClientFeatureId();
|
||||||
|
onCreateRef.current?.({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
id,
|
||||||
|
type: "city",
|
||||||
|
geometry_preset: "point",
|
||||||
|
entity_id: null,
|
||||||
|
entity_ids: [],
|
||||||
|
entity_name: null,
|
||||||
|
entity_type_id: null,
|
||||||
|
binding: [],
|
||||||
|
},
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const lineEngine = initLine(
|
||||||
|
map,
|
||||||
|
() => modeRef.current,
|
||||||
|
(geometry: Geometry) => {
|
||||||
|
const id = buildClientFeatureId();
|
||||||
|
onCreateRef.current?.({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
id,
|
||||||
|
type: "defense_line",
|
||||||
|
geometry_preset: "line",
|
||||||
|
entity_id: null,
|
||||||
|
entity_ids: [],
|
||||||
|
entity_name: null,
|
||||||
|
entity_type_id: null,
|
||||||
|
binding: [],
|
||||||
|
},
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const pathEngine = initPath(
|
||||||
|
map,
|
||||||
|
() => modeRef.current,
|
||||||
|
(geometry: Geometry) => {
|
||||||
|
const id = buildClientFeatureId();
|
||||||
|
onCreateRef.current?.({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
id,
|
||||||
|
type: "attack_route",
|
||||||
|
geometry_preset: "line",
|
||||||
|
entity_id: null,
|
||||||
|
entity_ids: [],
|
||||||
|
entity_name: null,
|
||||||
|
entity_type_id: null,
|
||||||
|
binding: [],
|
||||||
|
},
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const circleEngine = initCircle(
|
||||||
|
map,
|
||||||
|
() => modeRef.current,
|
||||||
|
(geometry: Geometry) => {
|
||||||
|
const id = buildClientFeatureId();
|
||||||
|
onCreateRef.current?.({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
id,
|
||||||
|
type: "war",
|
||||||
|
geometry_preset: "circle-area",
|
||||||
|
entity_id: null,
|
||||||
|
entity_ids: [],
|
||||||
|
entity_name: null,
|
||||||
|
entity_type_id: null,
|
||||||
|
binding: [],
|
||||||
|
},
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
engineBindingsRef.current = {
|
||||||
|
draw: drawingEngine,
|
||||||
|
select: selectEngine,
|
||||||
|
replay: selectEngine,
|
||||||
|
"add-line": lineEngine,
|
||||||
|
"add-path": pathEngine,
|
||||||
|
"add-circle": circleEngine,
|
||||||
|
};
|
||||||
|
|
||||||
|
mapCleanupFnsRef.current.push(
|
||||||
|
circleEngine.cleanup,
|
||||||
|
pathEngine.cleanup,
|
||||||
|
lineEngine.cleanup,
|
||||||
|
cleanupPoint,
|
||||||
|
selectEngine.cleanup,
|
||||||
|
drawingEngine.cleanup
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHoverMove = (event: maplibregl.MapMouseEvent) => {
|
||||||
|
const callback = onHoverFeatureChangeRef.current;
|
||||||
|
if (!callback) return;
|
||||||
|
|
||||||
|
const selectableLayers = getSelectableLayers(map);
|
||||||
|
if (!selectableLayers.length) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(event.point, {
|
||||||
|
layers: selectableLayers,
|
||||||
|
}) as maplibregl.MapGeoJSONFeature[];
|
||||||
|
|
||||||
|
const feature = features[0];
|
||||||
|
const rawFeatureId = feature?.id ?? feature?.properties?.id;
|
||||||
|
if (rawFeatureId === undefined || rawFeatureId === null) {
|
||||||
|
callback(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFeature =
|
||||||
|
draftRef.current.features.find(
|
||||||
|
(item) => String(item.properties.id) === String(rawFeatureId)
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
callback({
|
||||||
|
featureId: rawFeatureId,
|
||||||
|
feature: currentFeature,
|
||||||
|
point: { x: event.point.x, y: event.point.y },
|
||||||
|
lngLat: { lng: event.lngLat.lng, lat: event.lngLat.lat },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasMouseLeave = () => {
|
||||||
|
onHoverFeatureChangeRef.current?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("mousemove", handleHoverMove);
|
||||||
|
mapCleanupFnsRef.current.push(() => map.off("mousemove", handleHoverMove));
|
||||||
|
|
||||||
|
map.getCanvasContainer().addEventListener("mouseleave", handleCanvasMouseLeave);
|
||||||
|
mapCleanupFnsRef.current.push(() => {
|
||||||
|
map.getCanvasContainer().removeEventListener("mouseleave", handleCanvasMouseLeave);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allowGeometryEditing) {
|
||||||
|
editingEngineRef.current?.bindEditEvents(map);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupMapInteractions = () => {
|
||||||
|
for (const cleanupFn of mapCleanupFnsRef.current) {
|
||||||
|
cleanupFn();
|
||||||
|
}
|
||||||
|
mapCleanupFnsRef.current = [];
|
||||||
|
engineBindingsRef.current = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
editingEngineRef,
|
||||||
|
setupMapInteractions,
|
||||||
|
cleanupMapInteractions,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { getVectorTileTemplateUrl } from "@/uhm/api/tiles";
|
||||||
|
import {
|
||||||
|
COUNTRY_FILL_COLOR_EXPRESSION,
|
||||||
|
LINE_COLOR_BY_TYPE,
|
||||||
|
PATH_RENDER_BY_TYPE,
|
||||||
|
POLYGON_FILL_BY_TYPE,
|
||||||
|
POLYGON_OPACITY_BY_TYPE,
|
||||||
|
POLYGON_STROKE_BY_TYPE,
|
||||||
|
} from "@/uhm/lib/map/styles/style";
|
||||||
|
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||||
|
import { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
|
||||||
|
import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypeLayers";
|
||||||
|
import {
|
||||||
|
applyBackgroundLayerVisibility,
|
||||||
|
buildTypeMatchExpression,
|
||||||
|
ensurePathArrowIcon,
|
||||||
|
} from "./mapUtils";
|
||||||
|
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
|
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
|
|
||||||
|
export function getBaseMapStyle(): maplibregl.StyleSpecification {
|
||||||
|
return {
|
||||||
|
version: 8,
|
||||||
|
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
|
||||||
|
sources: {
|
||||||
|
base: {
|
||||||
|
type: "vector",
|
||||||
|
tiles: [getVectorTileTemplateUrl()],
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: "background",
|
||||||
|
type: "background",
|
||||||
|
paint: {
|
||||||
|
"background-color": "#0b1220",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "graticules-line",
|
||||||
|
type: "line",
|
||||||
|
source: "base",
|
||||||
|
"source-layer": "ne_10m_graticules_10",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#334155",
|
||||||
|
"line-width": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
0, 0.3,
|
||||||
|
4, 0.6,
|
||||||
|
6, 0.8,
|
||||||
|
],
|
||||||
|
"line-opacity": 0.55,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "land",
|
||||||
|
type: "fill",
|
||||||
|
source: "base",
|
||||||
|
"source-layer": "ne_10m_land",
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#1e293b",
|
||||||
|
"fill-opacity": 0.25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bg-countries-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "base",
|
||||||
|
"source-layer": "ne_10m_admin_0_countries",
|
||||||
|
paint: {
|
||||||
|
"fill-color": COUNTRY_FILL_COLOR_EXPRESSION,
|
||||||
|
"fill-opacity": 0.38,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bg-country-borders-line",
|
||||||
|
type: "line",
|
||||||
|
source: "base",
|
||||||
|
"source-layer": "ne_10m_admin_0_boundary_lines_land",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#cbd5e1",
|
||||||
|
"line-width": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
0, 0.2,
|
||||||
|
4, 0.5,
|
||||||
|
6, 1.1,
|
||||||
|
],
|
||||||
|
"line-opacity": 0.85,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "country-labels",
|
||||||
|
type: "symbol",
|
||||||
|
source: "base",
|
||||||
|
"source-layer": "country_labels",
|
||||||
|
minzoom: 0,
|
||||||
|
layout: {
|
||||||
|
"text-field": [
|
||||||
|
"coalesce",
|
||||||
|
["get", "NAME_EN"],
|
||||||
|
["get", "NAME"],
|
||||||
|
["get", "ADMIN"],
|
||||||
|
["get", "name"],
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
"text-size": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
0, 15,
|
||||||
|
1, 16,
|
||||||
|
2, 17,
|
||||||
|
4, 19,
|
||||||
|
6, 23,
|
||||||
|
],
|
||||||
|
"text-padding": 0,
|
||||||
|
"text-max-width": 10,
|
||||||
|
"text-allow-overlap": true,
|
||||||
|
"text-ignore-placement": true,
|
||||||
|
"symbol-placement": "point",
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "#e2e8f0",
|
||||||
|
"text-halo-color": "#0b1220",
|
||||||
|
"text-halo-width": 1.2,
|
||||||
|
"text-halo-blur": 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "regions-line",
|
||||||
|
type: "line",
|
||||||
|
source: "base",
|
||||||
|
"source-layer": "ne_10m_geography_regions_polys",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#475569",
|
||||||
|
"line-width": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
0, 0.2,
|
||||||
|
4, 0.6,
|
||||||
|
6, 1,
|
||||||
|
],
|
||||||
|
"line-opacity": 0.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lakes-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "base",
|
||||||
|
"source-layer": "ne_10m_lakes",
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#1d4ed8",
|
||||||
|
"fill-opacity": 0.45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rivers-line",
|
||||||
|
type: "line",
|
||||||
|
source: "base",
|
||||||
|
"source-layer": "ne_10m_rivers_lake_centerlines",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#38bdf8",
|
||||||
|
"line-width": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
0, 0.25,
|
||||||
|
4, 0.8,
|
||||||
|
6, 1.5,
|
||||||
|
],
|
||||||
|
"line-opacity": 0.85,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "geolines-line",
|
||||||
|
type: "line",
|
||||||
|
source: "base",
|
||||||
|
"source-layer": "ne_10m_geographic_lines",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#94a3b8",
|
||||||
|
"line-width": 1.2,
|
||||||
|
"line-opacity": 0.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupMapLayers(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
backgroundVisibility: BackgroundLayerVisibility,
|
||||||
|
highlightFeatures: FeatureCollection | null,
|
||||||
|
applyHighlightToMap: (fc: FeatureCollection) => void
|
||||||
|
) {
|
||||||
|
applyBackgroundLayerVisibility(map, backgroundVisibility);
|
||||||
|
const hasPathArrowIcon = ensurePathArrowIcon(map);
|
||||||
|
|
||||||
|
// preview (drawing)
|
||||||
|
map.addSource("draw-preview", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "draw-preview-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "draw-preview",
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#22c55e",
|
||||||
|
"fill-opacity": 0.4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "draw-preview-line",
|
||||||
|
type: "line",
|
||||||
|
source: "draw-preview",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#16a34a",
|
||||||
|
"line-width": 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addSource("draw-circle-preview", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "draw-circle-preview-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "draw-circle-preview",
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#0ea5e9",
|
||||||
|
"fill-opacity": 0.25,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "draw-circle-preview-line",
|
||||||
|
type: "line",
|
||||||
|
source: "draw-circle-preview",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#0284c7",
|
||||||
|
"line-width": 2,
|
||||||
|
"line-opacity": 0.95,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addSource("draw-line-preview", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "draw-line-preview-line",
|
||||||
|
type: "line",
|
||||||
|
source: "draw-line-preview",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#38bdf8",
|
||||||
|
"line-width": 3,
|
||||||
|
"line-opacity": 0.9,
|
||||||
|
"line-dasharray": [1.2, 0.9],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addSource("draw-path-preview", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "draw-path-preview-line",
|
||||||
|
type: "line",
|
||||||
|
source: "draw-path-preview",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#38bdf8",
|
||||||
|
"line-width": 3,
|
||||||
|
"line-opacity": 0.9,
|
||||||
|
"line-dasharray": [1.2, 0.9],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasPathArrowIcon) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "draw-path-preview-arrows",
|
||||||
|
type: "symbol",
|
||||||
|
source: "draw-path-preview",
|
||||||
|
layout: {
|
||||||
|
"symbol-placement": "line",
|
||||||
|
"symbol-spacing": 56,
|
||||||
|
"icon-image": PATH_ARROW_ICON_ID,
|
||||||
|
"icon-size": 0.45,
|
||||||
|
"icon-allow-overlap": true,
|
||||||
|
"icon-ignore-placement": true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// data
|
||||||
|
map.addSource("countries", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
promoteId: "id",
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addSource(PATH_ARROW_SOURCE_ID, {
|
||||||
|
type: "geojson",
|
||||||
|
data: EMPTY_FEATURE_COLLECTION,
|
||||||
|
promoteId: "id",
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addSource("places", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
promoteId: "id",
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addSource(POLYGON_LABEL_SOURCE_ID, {
|
||||||
|
type: "geojson",
|
||||||
|
data: EMPTY_FEATURE_COLLECTION,
|
||||||
|
promoteId: "id",
|
||||||
|
});
|
||||||
|
|
||||||
|
ensurePointGeotypeIcons(map);
|
||||||
|
|
||||||
|
const geotypeLayers = getAllGeotypeLayers("countries", PATH_ARROW_SOURCE_ID, "places");
|
||||||
|
for (const layer of geotypeLayers) {
|
||||||
|
map.addLayer(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geotypeLabelLayers = getAllGeotypeLabelLayers(POLYGON_LABEL_SOURCE_ID, "countries");
|
||||||
|
for (const layer of geotypeLabelLayers) {
|
||||||
|
map.addLayer(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// editing overlays
|
||||||
|
map.addSource("edit-shape", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
});
|
||||||
|
map.addSource("edit-handles", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "edit-shape-line",
|
||||||
|
type: "line",
|
||||||
|
source: "edit-shape",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#38bdf8",
|
||||||
|
"line-width": 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "edit-handles-circle",
|
||||||
|
type: "circle",
|
||||||
|
source: "edit-handles",
|
||||||
|
paint: {
|
||||||
|
"circle-color": "#f97316",
|
||||||
|
"circle-radius": 12,
|
||||||
|
"circle-stroke-color": "#0f172a",
|
||||||
|
"circle-stroke-width": 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addSource("entity-focus", {
|
||||||
|
type: "geojson",
|
||||||
|
data: EMPTY_FEATURE_COLLECTION,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "entity-focus-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "entity-focus",
|
||||||
|
filter: [
|
||||||
|
"any",
|
||||||
|
["==", ["geometry-type"], "Polygon"],
|
||||||
|
["==", ["geometry-type"], "MultiPolygon"],
|
||||||
|
],
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#fde047",
|
||||||
|
"fill-opacity": 0.2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "entity-focus-line",
|
||||||
|
type: "line",
|
||||||
|
source: "entity-focus",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#f59e0b",
|
||||||
|
"line-width": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
1, 2.4,
|
||||||
|
4, 4,
|
||||||
|
6, 5.5,
|
||||||
|
],
|
||||||
|
"line-opacity": 0.98,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "entity-focus-points",
|
||||||
|
type: "circle",
|
||||||
|
source: "entity-focus",
|
||||||
|
filter: [
|
||||||
|
"any",
|
||||||
|
["==", ["geometry-type"], "Point"],
|
||||||
|
["==", ["geometry-type"], "MultiPoint"],
|
||||||
|
],
|
||||||
|
paint: {
|
||||||
|
"circle-color": "#f8fafc",
|
||||||
|
"circle-radius": 8,
|
||||||
|
"circle-stroke-color": "#f59e0b",
|
||||||
|
"circle-stroke-width": 3,
|
||||||
|
"circle-opacity": 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applyHighlightToMap(highlightFeatures || EMPTY_FEATURE_COLLECTION);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { FeatureCollection } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
|
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
|
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||||
|
import { FEATURE_STATE_SOURCE_IDS, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
|
||||||
|
import {
|
||||||
|
applyBackgroundLayerVisibility,
|
||||||
|
buildPolygonLabelFeatureCollection,
|
||||||
|
buildPathArrowFeatureCollection,
|
||||||
|
decorateLineFeaturesWithLabels,
|
||||||
|
decoratePointFeaturesWithLabels,
|
||||||
|
filterDraftByBinding,
|
||||||
|
filterDraftByGeometryVisibility,
|
||||||
|
fitMapToFeatureCollection,
|
||||||
|
setSelectedFeatureState,
|
||||||
|
splitDraftFeatures,
|
||||||
|
} from "./mapUtils";
|
||||||
|
|
||||||
|
type UseMapSyncProps = {
|
||||||
|
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||||
|
draft: FeatureCollection;
|
||||||
|
labelContextDraft?: FeatureCollection;
|
||||||
|
backgroundVisibility: BackgroundLayerVisibility;
|
||||||
|
geometryVisibility?: Record<string, boolean>;
|
||||||
|
selectedFeatureIds: (string | number)[];
|
||||||
|
respectBindingFilter: boolean;
|
||||||
|
fitToDraftBounds: boolean;
|
||||||
|
fitBoundsKey?: string | number | null;
|
||||||
|
highlightFeatures?: FeatureCollection | null;
|
||||||
|
focusFeatureCollection?: FeatureCollection | null;
|
||||||
|
focusRequestKey?: string | number | null;
|
||||||
|
focusPadding?: number | maplibregl.PaddingOptions;
|
||||||
|
allowGeometryEditing: boolean;
|
||||||
|
editingEngineRef: React.MutableRefObject<{
|
||||||
|
editingRef: React.MutableRefObject<{ id: string | number } | null>;
|
||||||
|
clearEditing: () => void;
|
||||||
|
} | null>;
|
||||||
|
geolocationCenteredRef: React.MutableRefObject<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMapSync({
|
||||||
|
mapRef,
|
||||||
|
draft,
|
||||||
|
labelContextDraft,
|
||||||
|
backgroundVisibility,
|
||||||
|
geometryVisibility,
|
||||||
|
selectedFeatureIds,
|
||||||
|
respectBindingFilter,
|
||||||
|
fitToDraftBounds,
|
||||||
|
fitBoundsKey,
|
||||||
|
highlightFeatures,
|
||||||
|
focusFeatureCollection,
|
||||||
|
focusRequestKey,
|
||||||
|
focusPadding,
|
||||||
|
allowGeometryEditing,
|
||||||
|
editingEngineRef,
|
||||||
|
geolocationCenteredRef,
|
||||||
|
}: UseMapSyncProps) {
|
||||||
|
const draftRef = useRef<FeatureCollection>(draft);
|
||||||
|
const labelContextDraftRef = useRef<FeatureCollection | undefined>(labelContextDraft);
|
||||||
|
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||||
|
const geometryVisibilityRef = useRef<Record<string, boolean> | undefined>(geometryVisibility);
|
||||||
|
const selectedFeatureIdsRef = useRef<(string | number)[]>(selectedFeatureIds);
|
||||||
|
const respectBindingFilterRef = useRef(respectBindingFilter);
|
||||||
|
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
||||||
|
const highlightFeaturesRef = useRef<FeatureCollection | null>(highlightFeatures || null);
|
||||||
|
|
||||||
|
const fitBoundsAppliedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => { draftRef.current = draft; }, [draft]);
|
||||||
|
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
|
||||||
|
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
|
||||||
|
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
|
||||||
|
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
|
||||||
|
useEffect(() => { respectBindingFilterRef.current = respectBindingFilter; }, [respectBindingFilter]);
|
||||||
|
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
|
||||||
|
useEffect(() => { highlightFeaturesRef.current = highlightFeatures || null; }, [highlightFeatures]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fitBoundsAppliedRef.current = false;
|
||||||
|
}, [fitBoundsKey]);
|
||||||
|
|
||||||
|
const applyDraftToMap = useCallback((fc: FeatureCollection) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
|
||||||
|
const placesSource = map.getSource("places") as maplibregl.GeoJSONSource | undefined;
|
||||||
|
const polygonLabelSource = map.getSource(POLYGON_LABEL_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||||
|
|
||||||
|
if (!countriesSource || !placesSource || !polygonLabelSource) return;
|
||||||
|
|
||||||
|
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
|
||||||
|
if (map.getSource(sourceId)) {
|
||||||
|
map.removeFeatureState({ source: sourceId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleDraftRaw = respectBindingFilterRef.current
|
||||||
|
? filterDraftByBinding(fc, selectedFeatureIdsRef.current, highlightFeaturesRef.current)
|
||||||
|
: fc;
|
||||||
|
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
|
||||||
|
const labelContext = labelContextDraftRef.current || fc;
|
||||||
|
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||||
|
const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext);
|
||||||
|
const labeledPoints = decoratePointFeaturesWithLabels(points, labelContext);
|
||||||
|
const polygonLabels = buildPolygonLabelFeatureCollection(polygons, labelContext);
|
||||||
|
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
|
||||||
|
|
||||||
|
countriesSource.setData(labeledGeometries);
|
||||||
|
placesSource.setData(labeledPoints);
|
||||||
|
polygonLabelSource.setData(polygonLabels);
|
||||||
|
(map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes);
|
||||||
|
|
||||||
|
const currentSelectedIds = selectedFeatureIdsRef.current;
|
||||||
|
currentSelectedIds.forEach((id) => {
|
||||||
|
setSelectedFeatureState(map, id, true);
|
||||||
|
});
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (mapRef.current !== map) return;
|
||||||
|
currentSelectedIds.forEach((id) => {
|
||||||
|
setSelectedFeatureState(map, id, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
|
||||||
|
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
|
||||||
|
}
|
||||||
|
}, [mapRef]);
|
||||||
|
|
||||||
|
const applyHighlightToMap = useCallback((fc: FeatureCollection) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
|
||||||
|
if (!source) return;
|
||||||
|
source.setData(fc);
|
||||||
|
}, [mapRef]);
|
||||||
|
|
||||||
|
const tryCenterToUserLocation = useCallback(() => {
|
||||||
|
if (geolocationCenteredRef.current) return;
|
||||||
|
if (fitToDraftBoundsRef.current) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!("geolocation" in navigator)) return;
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
geolocationCenteredRef.current = true;
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
if (mapRef.current !== map) return;
|
||||||
|
const { longitude, latitude } = pos.coords;
|
||||||
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return;
|
||||||
|
|
||||||
|
const currentZoom = map.getZoom();
|
||||||
|
const nextZoom = Number.isFinite(currentZoom) ? Math.max(currentZoom, 5) : 5;
|
||||||
|
map.easeTo({ center: [longitude, latitude], zoom: nextZoom, duration: 900 });
|
||||||
|
},
|
||||||
|
() => { },
|
||||||
|
{ enableHighAccuracy: false, timeout: 4000, maximumAge: 60_000 }
|
||||||
|
);
|
||||||
|
}, [mapRef, geolocationCenteredRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
applyBackgroundLayerVisibility(map, backgroundVisibility);
|
||||||
|
}, [backgroundVisibility, mapRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined;
|
||||||
|
source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION);
|
||||||
|
}, [highlightFeatures, mapRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyDraftToMap(draft);
|
||||||
|
const editingId = editingEngineRef.current?.editingRef?.current?.id;
|
||||||
|
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
|
||||||
|
const stillExists = draft.features.some((f) => f.properties.id === editingId);
|
||||||
|
if (!stillExists) {
|
||||||
|
editingEngineRef.current?.clearEditing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
allowGeometryEditing,
|
||||||
|
draft,
|
||||||
|
labelContextDraft,
|
||||||
|
selectedFeatureIds,
|
||||||
|
respectBindingFilter,
|
||||||
|
geometryVisibility,
|
||||||
|
highlightFeatures,
|
||||||
|
applyDraftToMap,
|
||||||
|
editingEngineRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusRequestKey === null || focusRequestKey === undefined) return;
|
||||||
|
const map = mapRef.current;
|
||||||
|
const target = focusFeatureCollection;
|
||||||
|
if (!target || !target.features.length) return;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
if (cancelled || mapRef.current !== map || !map.isStyleLoaded()) return;
|
||||||
|
fitMapToFeatureCollection(map, target, focusPadding, {
|
||||||
|
duration: 550,
|
||||||
|
maxZoom: 10,
|
||||||
|
pointZoom: 9,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
rafId = requestAnimationFrame(focus);
|
||||||
|
} else {
|
||||||
|
map.once("idle", focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [focusFeatureCollection, focusPadding, focusRequestKey, mapRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyDraftToMap,
|
||||||
|
applyHighlightToMap,
|
||||||
|
tryCenterToUserLocation,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/timeline";
|
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/utils/timeline";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
year: number;
|
year: number;
|
||||||
onYearChange: (year: number) => void;
|
onYearChange: (year: number) => void;
|
||||||
|
timeRange?: number;
|
||||||
|
onTimeRangeChange?: (range: number) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
statusText?: string | null;
|
statusText?: string | null;
|
||||||
@@ -15,6 +17,8 @@ type Props = {
|
|||||||
export default function TimelineBar({
|
export default function TimelineBar({
|
||||||
year,
|
year,
|
||||||
onYearChange,
|
onYearChange,
|
||||||
|
timeRange,
|
||||||
|
onTimeRangeChange,
|
||||||
isLoading,
|
isLoading,
|
||||||
disabled,
|
disabled,
|
||||||
statusText,
|
statusText,
|
||||||
@@ -34,6 +38,12 @@ export default function TimelineBar({
|
|||||||
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
|
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTimeRangeChange = (nextValue: number) => {
|
||||||
|
if (!onTimeRangeChange) return;
|
||||||
|
const safe = Number.isFinite(nextValue) ? Math.trunc(nextValue) : 0;
|
||||||
|
onTimeRangeChange(Math.max(0, Math.min(30, safe)));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -148,6 +158,41 @@ export default function TimelineBar({
|
|||||||
outline: "none",
|
outline: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{typeof timeRange === "number" && onTimeRangeChange ? (
|
||||||
|
<label
|
||||||
|
title="time_range (0-30)"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
color: "#94a3b8",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
opacity: effectiveDisabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "12px" }}>Range</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={30}
|
||||||
|
step={1}
|
||||||
|
value={Math.max(0, Math.min(30, Math.trunc(timeRange)))}
|
||||||
|
onChange={(event) => handleTimeRangeChange(Number(event.target.value))}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
aria-label="Timeline range"
|
||||||
|
style={{
|
||||||
|
width: "84px",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "6px 8px",
|
||||||
|
background: "rgba(15, 23, 42, 0.7)",
|
||||||
|
color: "#f8fafc",
|
||||||
|
fontSize: "13px",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { Entity } from "@/uhm/api/entities";
|
||||||
|
import type { Wiki } from "@/uhm/api/wikis";
|
||||||
|
|
||||||
|
type TocItem = {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entity: Entity | null;
|
||||||
|
wiki: Wiki | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tiptapJsonToPlainText(node: unknown): string {
|
||||||
|
if (node == null) return "";
|
||||||
|
if (typeof node === "string") return node;
|
||||||
|
if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join("");
|
||||||
|
|
||||||
|
if (isRecord(node)) {
|
||||||
|
if (node.type === "text" && typeof node.text === "string") return node.text;
|
||||||
|
if (node.type === "hardBreak") return "\n";
|
||||||
|
if ("content" in node) return tiptapJsonToPlainText(node.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(input: string): string {
|
||||||
|
return input
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWikiContentToHtml(raw: string | null | undefined): string {
|
||||||
|
const value = String(raw || "").trim();
|
||||||
|
if (!value.length) return "";
|
||||||
|
|
||||||
|
if (value[0] === "<") return value;
|
||||||
|
|
||||||
|
if (value[0] === "{") {
|
||||||
|
try {
|
||||||
|
const json: unknown = JSON.parse(value);
|
||||||
|
const text = tiptapJsonToPlainText(json).trim();
|
||||||
|
if (!text.length) return "";
|
||||||
|
return `<p>${escapeHtml(text).replace(/\n/g, "<br/>")}</p>`;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<p>${escapeHtml(value).replace(/\n/g, "<br/>")}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifyHeading(raw: string): string {
|
||||||
|
const input = String(raw || "").trim();
|
||||||
|
if (!input.length) return "";
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalHref(href: string): boolean {
|
||||||
|
const h = href.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
h.startsWith("http://") ||
|
||||||
|
h.startsWith("https://") ||
|
||||||
|
h.startsWith("mailto:") ||
|
||||||
|
h.startsWith("tel:") ||
|
||||||
|
h.startsWith("sms:")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(inputHtml, "text/html");
|
||||||
|
|
||||||
|
for (const el of Array.from(doc.querySelectorAll("script"))) el.remove();
|
||||||
|
|
||||||
|
for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
|
||||||
|
const href = String(a.getAttribute("href") || "").trim();
|
||||||
|
if (!href.length) continue;
|
||||||
|
if (href === "__missing__") continue;
|
||||||
|
if (href.startsWith("#")) continue;
|
||||||
|
if (href.startsWith("/")) continue;
|
||||||
|
|
||||||
|
if (isExternalHref(href)) {
|
||||||
|
a.setAttribute("target", "_blank");
|
||||||
|
a.setAttribute("rel", "noopener noreferrer");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = href.match(/^([^?#]+)([?#].*)?$/);
|
||||||
|
const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim();
|
||||||
|
if (!slugPart.length) continue;
|
||||||
|
a.setAttribute("href", `#wiki:${slugPart}`);
|
||||||
|
a.setAttribute("data-wiki-slug", slugPart);
|
||||||
|
a.setAttribute("target", "_self");
|
||||||
|
}
|
||||||
|
|
||||||
|
const toc: TocItem[] = [];
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6"));
|
||||||
|
for (const h of headings) {
|
||||||
|
const text = String(h.textContent || "").trim();
|
||||||
|
if (!text.length) continue;
|
||||||
|
|
||||||
|
const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1;
|
||||||
|
const existingId = String(h.getAttribute("id") || "").trim();
|
||||||
|
if (existingId) {
|
||||||
|
toc.push({ id: existingId, level, text });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = slugifyHeading(text) || "heading";
|
||||||
|
const nextCount = (seen.get(base) || 0) + 1;
|
||||||
|
seen.set(base, nextCount);
|
||||||
|
const id = nextCount === 1 ? base : `${base}-${nextCount}`;
|
||||||
|
|
||||||
|
h.setAttribute("id", id);
|
||||||
|
toc.push({ id, level, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { html: doc.body.innerHTML, toc };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicWikiSidebar({
|
||||||
|
entity,
|
||||||
|
wiki,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onWikiLinkRequest,
|
||||||
|
}: Props) {
|
||||||
|
const contentRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||||
|
const processedWiki = useMemo(() => {
|
||||||
|
if (!wiki) return { html: "", toc: [] as TocItem[] };
|
||||||
|
|
||||||
|
const html = normalizeWikiContentToHtml(wiki.content ?? "");
|
||||||
|
try {
|
||||||
|
return prepareWikiHtml(html);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to process sidebar wiki HTML", err);
|
||||||
|
return { html, toc: [] as TocItem[] };
|
||||||
|
}
|
||||||
|
}, [wiki]);
|
||||||
|
const renderHtml = processedWiki.html;
|
||||||
|
const toc = processedWiki.toc;
|
||||||
|
const effectiveActiveHeadingId = toc.some((item) => item.id === activeHeadingId)
|
||||||
|
? activeHeadingId
|
||||||
|
: (toc[0]?.id ?? null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toc.length) return;
|
||||||
|
const root = contentRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const headings = toc
|
||||||
|
.map((item) => root.querySelector<HTMLElement>(`#${CSS.escape(item.id)}`))
|
||||||
|
.filter((item): item is HTMLElement => Boolean(item));
|
||||||
|
if (!headings.length) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((entry) => entry.isIntersecting)
|
||||||
|
.sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0));
|
||||||
|
const top = visible[0]?.target as HTMLElement | undefined;
|
||||||
|
if (top?.id) setActiveHeadingId(top.id);
|
||||||
|
},
|
||||||
|
{ root: null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] }
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const heading of headings) observer.observe(heading);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [toc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = contentRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null;
|
||||||
|
if (!link) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const slug = String(link.getAttribute("data-wiki-slug") || "").trim();
|
||||||
|
if (!slug.length) return;
|
||||||
|
onWikiLinkRequest({ slug, rect: link.getBoundingClientRect() });
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener("click", handleClick);
|
||||||
|
return () => root.removeEventListener("click", handleClick);
|
||||||
|
}, [onWikiLinkRequest, renderHtml]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950">
|
||||||
|
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-800">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.08em] text-gray-500 dark:text-gray-400">
|
||||||
|
Wiki
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold leading-tight text-gray-900 dark:text-gray-100">
|
||||||
|
{entity?.name?.trim() || wiki?.title?.trim() || "Wiki"}
|
||||||
|
</div>
|
||||||
|
{entity?.description?.trim() ? (
|
||||||
|
<div className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
|
{entity.description.trim()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? (
|
||||||
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{wiki.title.trim()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-gray-200 text-sm text-gray-500 transition hover:bg-gray-50 hover:text-gray-800 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.04] dark:hover:text-gray-100"
|
||||||
|
aria-label="Close wiki sidebar"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toc.length ? (
|
||||||
|
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{toc.slice(0, 8).map((item) => {
|
||||||
|
const isActive = effectiveActiveHeadingId === item.id;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={item.id}
|
||||||
|
href={`#${item.id}`}
|
||||||
|
className={`shrink-0 rounded-full px-3 py-1 text-xs transition ${isActive
|
||||||
|
? "bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-300"
|
||||||
|
: "bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/[0.06]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3 px-4 py-4">
|
||||||
|
<div className="h-4 w-28 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
|
||||||
|
<div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
|
||||||
|
<div className="h-4 w-4/5 animate-pulse rounded bg-gray-100 dark:bg-white/[0.06]" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="px-4 py-4 text-sm text-red-600 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : wiki ? (
|
||||||
|
<div
|
||||||
|
ref={contentRootRef}
|
||||||
|
className="uhm-wiki-sidebar-view ql-editor text-sm text-gray-900 dark:text-gray-100"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderHtml }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Entity này chưa có wiki liên kết.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor {
|
||||||
|
height: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
padding: 18px 18px 22px;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor p {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h1 {
|
||||||
|
margin: 1.15em 0 0.6em;
|
||||||
|
font-size: 1.6em;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h2 {
|
||||||
|
margin: 1.05em 0 0.55em;
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h3,
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h4,
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h5,
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor h6 {
|
||||||
|
margin: 0.95em 0 0.45em;
|
||||||
|
font-size: 1.05em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor ul,
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor ol {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor blockquote {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 3px solid rgba(148, 163, 184, 0.6);
|
||||||
|
color: rgba(71, 85, 105, 1);
|
||||||
|
}
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor blockquote {
|
||||||
|
border-left-color: rgba(100, 116, 139, 0.6);
|
||||||
|
color: rgba(203, 213, 225, 0.95);
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor pre {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 1);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(248, 250, 252, 1);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor pre {
|
||||||
|
border-color: rgba(51, 65, 85, 1);
|
||||||
|
background: rgba(2, 6, 23, 0.4);
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href]:not([href=""]):not([href="__missing__"]) {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a:not([href]),
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a[href=""],
|
||||||
|
.uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a:not([href]),
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href=""],
|
||||||
|
:is(.dark *) .uhm-wiki-sidebar-view.ql-editor a[href="__missing__"] {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import {
|
|||||||
BACKGROUND_LAYER_OPTIONS,
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
BackgroundLayerVisibility,
|
BackgroundLayerVisibility,
|
||||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
} from "@/uhm/lib/backgroundLayers";
|
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
|
|
||||||
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
} from "@/uhm/types/geo";
|
} from "@/uhm/types/geo";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
|
|
||||||
export type Change = GeometryChange;
|
export type Change = GeometryChange;
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Entity } from "@/uhm/types/entities";
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||||
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
import { newId } from "@/uhm/lib/id";
|
import { newId } from "@/uhm/lib/utils/id";
|
||||||
|
|
||||||
export function mergeEntitySearchResults(
|
export function mergeEntitySearchResults(
|
||||||
remoteRows: Entity[],
|
remoteRows: Entity[],
|
||||||
|
|||||||
+60
-64
@@ -2,17 +2,17 @@ import { useCallback } from "react";
|
|||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { ApiError } from "@/uhm/api/http";
|
import { ApiError } from "@/uhm/api/http";
|
||||||
import {
|
import {
|
||||||
createSection,
|
createProject,
|
||||||
createSectionCommit,
|
createProjectCommit,
|
||||||
fetchSectionCommits,
|
fetchProjectCommits,
|
||||||
fetchSections,
|
fetchProjects,
|
||||||
openSectionEditor,
|
openSectionEditor,
|
||||||
submitSection,
|
submitSection,
|
||||||
} from "@/uhm/api/sections";
|
} 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, Section, SectionCommit, SectionState, EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
import type { EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
|
|
||||||
@@ -27,9 +27,9 @@ type Options = {
|
|||||||
editor: EditorDraftApi;
|
editor: EditorDraftApi;
|
||||||
editorUserId: string;
|
editorUserId: string;
|
||||||
emptyFeatureCollection: FeatureCollection;
|
emptyFeatureCollection: FeatureCollection;
|
||||||
activeSection: Section | null;
|
activeSection: Project | null;
|
||||||
sectionState: SectionState | null;
|
projectState: ProjectState | null;
|
||||||
selectedSectionId: string;
|
selectedProjectId: string;
|
||||||
newSectionTitle: string;
|
newSectionTitle: string;
|
||||||
pendingSaveCount: number;
|
pendingSaveCount: number;
|
||||||
snapshotEntities: EntitySnapshot[];
|
snapshotEntities: EntitySnapshot[];
|
||||||
@@ -37,54 +37,52 @@ type Options = {
|
|||||||
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
|
||||||
baselineSnapshot: EditorSnapshot | null;
|
baselineSnapshot: EditorSnapshot | null;
|
||||||
commitTitle: string;
|
commitTitle: string;
|
||||||
commitNote: string;
|
setActiveSection: Dispatch<SetStateAction<Project | null>>;
|
||||||
setActiveSection: Dispatch<SetStateAction<Section | null>>;
|
setSelectedProjectId: Dispatch<SetStateAction<string>>;
|
||||||
setSelectedSectionId: Dispatch<SetStateAction<string>>;
|
setProjectState: Dispatch<SetStateAction<ProjectState | null>>;
|
||||||
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
|
|
||||||
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
setBaselineSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
||||||
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||||
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
setProjectCommits: Dispatch<SetStateAction<ProjectCommit[]>>;
|
||||||
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
setSnapshotEntities: Dispatch<SetStateAction<EntitySnapshot[]>>;
|
||||||
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
setSnapshotWikis: Dispatch<SetStateAction<WikiSnapshot[]>>;
|
||||||
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
setSnapshotEntityWikiLinks: Dispatch<SetStateAction<EntityWikiLinkSnapshot[]>>;
|
||||||
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
setSelectedFeatureIds: Dispatch<SetStateAction<FeatureId[]>>;
|
||||||
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
setIsSaving: Dispatch<SetStateAction<boolean>>;
|
setIsSaving: Dispatch<SetStateAction<boolean>>;
|
||||||
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
|
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
|
||||||
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
|
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
|
||||||
setAvailableSections: Dispatch<SetStateAction<Section[]>>;
|
setAvailableSections: Dispatch<SetStateAction<Project[]>>;
|
||||||
setNewSectionTitle: Dispatch<SetStateAction<string>>;
|
setNewSectionTitle: Dispatch<SetStateAction<string>>;
|
||||||
setCommitTitle: Dispatch<SetStateAction<string>>;
|
setCommitTitle: Dispatch<SetStateAction<string>>;
|
||||||
setCommitNote: Dispatch<SetStateAction<string>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useSectionCommands(options: Options) {
|
export function useProjectCommands(options: Options) {
|
||||||
const openSectionForEditing = useCallback(async (sectionId: string) => {
|
const openSectionForEditing = useCallback(async (projectId: string) => {
|
||||||
const editorPayload = await openSectionEditor(sectionId);
|
const editorPayload = await openSectionEditor(projectId);
|
||||||
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||||
// When starting a fresh editor session from a commit snapshot, treat all rows as baseline state:
|
// When starting a fresh editor session from a commit snapshot, treat all rows as baseline state:
|
||||||
// operations should not carry over as deltas into the next commit.
|
// operations should not carry over as deltas into the next commit.
|
||||||
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
const sessionSnapshot = snapshot ? toEditorSessionSnapshot(snapshot) : null;
|
||||||
const commits = await fetchSectionCommits(sectionId);
|
const commits = await fetchProjectCommits(projectId);
|
||||||
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
const nextInitialData = sessionSnapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||||
|
|
||||||
options.setActiveSection(editorPayload.section);
|
options.setActiveSection(editorPayload.project);
|
||||||
options.setSelectedSectionId(editorPayload.section.id);
|
options.setSelectedProjectId(editorPayload.project.id);
|
||||||
options.setSectionState(editorPayload.state);
|
options.setProjectState(editorPayload.state);
|
||||||
options.setBaselineSnapshot(sessionSnapshot);
|
options.setBaselineSnapshot(sessionSnapshot);
|
||||||
options.setInitialData(nextInitialData);
|
options.setInitialData(nextInitialData);
|
||||||
options.setSectionCommits(commits);
|
options.setProjectCommits(commits);
|
||||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||||
options.setSelectedFeatureId(null);
|
options.setSelectedFeatureIds([]);
|
||||||
options.setEntityFormStatus(null);
|
options.setEntityFormStatus(null);
|
||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
||||||
const commitSection = useCallback(async () => {
|
const commitSection = useCallback(async () => {
|
||||||
if (!options.activeSection || !options.sectionState) {
|
if (!options.activeSection || !options.projectState) {
|
||||||
options.setEntityStatus("Chưa mở được section editor.");
|
options.setEntityStatus("Chưa mở được project editor.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.pendingSaveCount <= 0) {
|
if (options.pendingSaveCount <= 0) {
|
||||||
@@ -97,7 +95,7 @@ export function useSectionCommands(options: Options) {
|
|||||||
options.setEntityStatus(null);
|
options.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
const snapshot = buildEditorSnapshot({
|
const snapshot = buildEditorSnapshot({
|
||||||
section: options.activeSection,
|
project: options.activeSection,
|
||||||
draft: options.editor.draft,
|
draft: options.editor.draft,
|
||||||
changes: geometryChanges,
|
changes: geometryChanges,
|
||||||
snapshotEntities: options.snapshotEntities,
|
snapshotEntities: options.snapshotEntities,
|
||||||
@@ -107,13 +105,12 @@ export function useSectionCommands(options: Options) {
|
|||||||
hasPersistedFeature: options.editor.hasPersistedFeature,
|
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||||
});
|
});
|
||||||
const editSummary = options.commitTitle.trim()
|
const editSummary = options.commitTitle.trim()
|
||||||
|| options.commitNote.trim()
|
|
||||||
|| `Edit ${new Date().toLocaleString()}`;
|
|| `Edit ${new Date().toLocaleString()}`;
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -127,13 +124,13 @@ export function useSectionCommands(options: Options) {
|
|||||||
// If stringify fails, let API call throw a more actionable error downstream.
|
// If stringify fails, let API call throw a more actionable error downstream.
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createSectionCommit(options.activeSection.id, {
|
const result = await createProjectCommit(options.activeSection.id, {
|
||||||
snapshot,
|
snapshot,
|
||||||
edit_summary: editSummary,
|
edit_summary: editSummary,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
const sessionSnapshot = toEditorSessionSnapshot(snapshot);
|
||||||
options.setSectionState(result.state);
|
options.setProjectState(result.state);
|
||||||
options.setBaselineSnapshot(sessionSnapshot);
|
options.setBaselineSnapshot(sessionSnapshot);
|
||||||
options.setSnapshotEntities(sessionSnapshot.entities || []);
|
options.setSnapshotEntities(sessionSnapshot.entities || []);
|
||||||
options.setSnapshotWikis(sessionSnapshot.wikis || []);
|
options.setSnapshotWikis(sessionSnapshot.wikis || []);
|
||||||
@@ -141,8 +138,7 @@ export function useSectionCommands(options: Options) {
|
|||||||
options.setInitialData(options.editor.draft);
|
options.setInitialData(options.editor.draft);
|
||||||
options.editor.clearChanges();
|
options.editor.clearChanges();
|
||||||
options.setCommitTitle("");
|
options.setCommitTitle("");
|
||||||
options.setCommitNote("");
|
options.setProjectCommits(await fetchProjectCommits(options.activeSection.id));
|
||||||
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
|
||||||
options.setEntityFormStatus("Đã tạo commit.");
|
options.setEntityFormStatus("Đã tạo commit.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -158,26 +154,26 @@ export function useSectionCommands(options: Options) {
|
|||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
||||||
const openSelectedSection = useCallback(async () => {
|
const openSelectedSection = useCallback(async () => {
|
||||||
const sectionId = options.selectedSectionId.trim();
|
const projectId = options.selectedProjectId.trim();
|
||||||
if (!sectionId) {
|
if (!projectId) {
|
||||||
options.setEntityStatus("Hãy chọn section để mở.");
|
options.setEntityStatus("Hãy chọn project để mở.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.pendingSaveCount > 0) {
|
if (options.pendingSaveCount > 0) {
|
||||||
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section khác sẽ bỏ các thay đổi này. Tiếp tục?");
|
const confirmed = window.confirm("Project hiện tại có thay đổi chưa Commit. Mở project khác sẽ bỏ các thay đổi này. Tiếp tục?");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
options.setIsOpeningSection(true);
|
options.setIsOpeningSection(true);
|
||||||
options.setEntityStatus(null);
|
options.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
await openSectionForEditing(sectionId);
|
await openSectionForEditing(projectId);
|
||||||
options.setEntityStatus("Đã mở section để chỉnh sửa.");
|
options.setEntityStatus("Đã mở project để chỉnh sửa.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
options.setEntityStatus(`Mở section thất bại: ${err.body}`);
|
options.setEntityStatus(`Mở project thất bại: ${err.body}`);
|
||||||
} else {
|
} else {
|
||||||
options.setEntityStatus("Mở section thất bại.");
|
options.setEntityStatus("Mở project thất bại.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
options.setIsOpeningSection(false);
|
options.setIsOpeningSection(false);
|
||||||
@@ -187,40 +183,40 @@ export function useSectionCommands(options: Options) {
|
|||||||
const createAndOpenSection = useCallback(async () => {
|
const createAndOpenSection = useCallback(async () => {
|
||||||
const title = options.newSectionTitle.trim();
|
const title = options.newSectionTitle.trim();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
options.setEntityStatus("Tên section là bắt buộc.");
|
options.setEntityStatus("Tên project là bắt buộc.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.pendingSaveCount > 0) {
|
if (options.pendingSaveCount > 0) {
|
||||||
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section mới sẽ bỏ các thay đổi này. Tiếp tục?");
|
const confirmed = window.confirm("Project hiện tại có thay đổi chưa Commit. Tạo project mới sẽ bỏ các thay đổi này. Tiếp tục?");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
options.setIsOpeningSection(true);
|
options.setIsOpeningSection(true);
|
||||||
options.setEntityStatus(null);
|
options.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
const section = await createSection({
|
const project = await createProject({
|
||||||
title,
|
title,
|
||||||
description: null,
|
description: null,
|
||||||
});
|
});
|
||||||
const sections = await fetchSections();
|
const projects = await fetchProjects();
|
||||||
options.setAvailableSections(sections);
|
options.setAvailableSections(projects);
|
||||||
options.setNewSectionTitle("");
|
options.setNewSectionTitle("");
|
||||||
await openSectionForEditing(section.id);
|
await openSectionForEditing(project.id);
|
||||||
options.setEntityStatus("Đã tạo và mở section mới.");
|
options.setEntityStatus("Đã tạo và mở project mới.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
options.setEntityStatus(`Tạo section thất bại: ${err.body}`);
|
options.setEntityStatus(`Tạo project thất bại: ${err.body}`);
|
||||||
} else {
|
} else {
|
||||||
options.setEntityStatus("Tạo section thất bại.");
|
options.setEntityStatus("Tạo project thất bại.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
options.setIsOpeningSection(false);
|
options.setIsOpeningSection(false);
|
||||||
}
|
}
|
||||||
}, [openSectionForEditing, options]);
|
}, [openSectionForEditing, options]);
|
||||||
|
|
||||||
const submitCurrentSection = useCallback(async () => {
|
const submitCurrentSection = useCallback(async (content: string) => {
|
||||||
if (!options.activeSection || !options.sectionState?.head_commit_id) {
|
if (!options.activeSection || !options.projectState?.head_commit_id) {
|
||||||
options.setEntityStatus("Section hiện tại chưa có head để submit.");
|
options.setEntityStatus("Project hiện tại chưa có head để submit.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.pendingSaveCount > 0) {
|
if (options.pendingSaveCount > 0) {
|
||||||
@@ -231,7 +227,7 @@ export function useSectionCommands(options: Options) {
|
|||||||
options.setIsSubmitting(true);
|
options.setIsSubmitting(true);
|
||||||
options.setEntityStatus(null);
|
options.setEntityStatus(null);
|
||||||
try {
|
try {
|
||||||
const submission = await submitSection(options.activeSection.id);
|
const submission = await submitSection(options.activeSection.id, content);
|
||||||
options.setEntityStatus(`Đã submit, submission ${submission.id}.`);
|
options.setEntityStatus(`Đã submit, submission ${submission.id}.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -245,8 +241,8 @@ export function useSectionCommands(options: Options) {
|
|||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
||||||
const restoreCommit = useCallback(async (commitId: string) => {
|
const restoreCommit = useCallback(async (commitId: string) => {
|
||||||
if (!options.activeSection || !options.sectionState) {
|
if (!options.activeSection || !options.projectState) {
|
||||||
options.setEntityStatus("Chưa mở được section editor.");
|
options.setEntityStatus("Chưa mở được project editor.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.pendingSaveCount > 0) {
|
if (options.pendingSaveCount > 0) {
|
||||||
@@ -259,8 +255,8 @@ export function useSectionCommands(options: Options) {
|
|||||||
try {
|
try {
|
||||||
// FE-only restore: load snapshot from selected commit and apply to editor state.
|
// FE-only restore: load snapshot from selected commit and apply to editor state.
|
||||||
// Do NOT move project's head commit on backend.
|
// Do NOT move project's head commit on backend.
|
||||||
const commits = await fetchSectionCommits(options.activeSection.id);
|
const commits = await fetchProjectCommits(options.activeSection.id);
|
||||||
const target = commits.find((c: SectionCommit) => c.id === commitId) || null;
|
const target = commits.find((c: ProjectCommit) => c.id === commitId) || null;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
options.setEntityStatus("Không tìm thấy commit để restore.");
|
options.setEntityStatus("Không tìm thấy commit để restore.");
|
||||||
return;
|
return;
|
||||||
@@ -275,11 +271,11 @@ export function useSectionCommands(options: Options) {
|
|||||||
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
options.setSnapshotEntities(sessionSnapshot?.entities || []);
|
||||||
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
options.setSnapshotWikis(sessionSnapshot?.wikis || []);
|
||||||
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
options.setSnapshotEntityWikiLinks(sessionSnapshot?.entity_wiki || []);
|
||||||
options.setSelectedFeatureId(null);
|
options.setSelectedFeatureIds([]);
|
||||||
options.setEntityFormStatus(null);
|
options.setEntityFormStatus(null);
|
||||||
|
|
||||||
// Refresh commits list for UI, but keep sectionState/head as-is.
|
// Refresh commits list for UI, but keep projectState/head as-is.
|
||||||
options.setSectionCommits(commits);
|
options.setProjectCommits(commits);
|
||||||
options.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
|
options.setEntityFormStatus("Đã load snapshot từ commit (không đổi head trên BE).");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { EntityGeometryPreset } from "@/uhm/lib/entityTypeOptions";
|
import type { GeometryPreset } from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||||
|
|
||||||
export type EditorMode =
|
export type EditorMode =
|
||||||
| "idle"
|
| "idle"
|
||||||
@@ -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;
|
||||||
@@ -38,4 +39,4 @@ export type CreatedEntitySummary = {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeometryPreset = EntityGeometryPreset;
|
export type { GeometryPreset };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
BackgroundLayerVisibility,
|
BackgroundLayerVisibility,
|
||||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
} from "@/uhm/lib/backgroundLayers";
|
} from "@/uhm/lib/map/styles/backgroundLayers";
|
||||||
|
|
||||||
export function useBackgroundSessionState() {
|
export function useBackgroundSessionState() {
|
||||||
// Trạng thái bật/tắt layer nền (khởi tạo default hidden; sẽ load từ storage ở page).
|
// Trạng thái bật/tắt layer nền (khởi tạo default hidden; sẽ load từ storage ở page).
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export function useEntitySessionState() {
|
|||||||
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
|
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
|
||||||
// Thông báo trạng thái/lỗi liên quan entity/session.
|
// Thông báo trạng thái/lỗi liên quan entity/session.
|
||||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||||
// Feature đang được chọn để thao tác bind entities/metadata.
|
// Features đang được chọn để thao tác bind entities/metadata.
|
||||||
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
|
const [selectedFeatureIds, setSelectedFeatureIds] = useState<FeatureId[]>([]);
|
||||||
// Form tạo entity mới (độc lập).
|
// Form tạo entity mới (độc lập).
|
||||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -50,8 +50,8 @@ export function useEntitySessionState() {
|
|||||||
setSnapshotEntities,
|
setSnapshotEntities,
|
||||||
entityStatus,
|
entityStatus,
|
||||||
setEntityStatus,
|
setEntityStatus,
|
||||||
selectedFeatureId,
|
selectedFeatureIds,
|
||||||
setSelectedFeatureId,
|
setSelectedFeatureIds,
|
||||||
entityForm,
|
entityForm,
|
||||||
setEntityForm,
|
setEntityForm,
|
||||||
selectedGeometryEntityIds,
|
selectedGeometryEntityIds,
|
||||||
|
|||||||
+22
-26
@@ -1,15 +1,15 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
|
import type { EditorSnapshot, Project, ProjectCommit, ProjectState } from "@/uhm/types/projects";
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
defaultEditorUserId: string;
|
defaultEditorUserId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SectionTask = "idle" | "saving" | "submitting" | "opening-section";
|
type SectionTask = "idle" | "saving" | "submitting" | "opening-project";
|
||||||
|
|
||||||
export function useSectionSessionState(options: Options) {
|
export function useProjectSessionState(options: Options) {
|
||||||
// Single state machine cho các tác vụ async của section (saving/submitting/opening).
|
// Single state machine cho các tác vụ async của project (saving/submitting/opening).
|
||||||
const [sectionTask, setSectionTask] = useState<SectionTask>("idle");
|
const [sectionTask, setSectionTask] = useState<SectionTask>("idle");
|
||||||
const setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
|
const setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
|
||||||
setSectionTask((prev) => {
|
setSectionTask((prev) => {
|
||||||
@@ -22,7 +22,7 @@ export function useSectionSessionState(options: Options) {
|
|||||||
|
|
||||||
const isSaving = sectionTask === "saving";
|
const isSaving = sectionTask === "saving";
|
||||||
const isSubmitting = sectionTask === "submitting";
|
const isSubmitting = sectionTask === "submitting";
|
||||||
const isOpeningSection = sectionTask === "opening-section";
|
const isOpeningSection = sectionTask === "opening-project";
|
||||||
const setIsSaving: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
const setIsSaving: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||||
setTaskFlag("saving", next);
|
setTaskFlag("saving", next);
|
||||||
}, [setTaskFlag]);
|
}, [setTaskFlag]);
|
||||||
@@ -30,27 +30,25 @@ export function useSectionSessionState(options: Options) {
|
|||||||
setTaskFlag("submitting", next);
|
setTaskFlag("submitting", next);
|
||||||
}, [setTaskFlag]);
|
}, [setTaskFlag]);
|
||||||
const setIsOpeningSection: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
const setIsOpeningSection: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||||
setTaskFlag("opening-section", next);
|
setTaskFlag("opening-project", next);
|
||||||
}, [setTaskFlag]);
|
}, [setTaskFlag]);
|
||||||
|
|
||||||
// Danh sách sections để user chọn mở.
|
// Danh sách projects để user chọn mở.
|
||||||
const [availableSections, setAvailableSections] = useState<Section[]>([]);
|
const [availableSections, setAvailableSections] = useState<Project[]>([]);
|
||||||
// Section ID đang được chọn trong dropdown.
|
// Project ID đang được chọn trong dropdown.
|
||||||
const [selectedSectionId, setSelectedSectionId] = useState("");
|
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||||
// Title section mới (để create).
|
// Title project mới (để create).
|
||||||
const [newSectionTitle, setNewSectionTitle] = useState("");
|
const [newSectionTitle, setNewSectionTitle] = useState("");
|
||||||
// Input title cho commit.
|
// Input title cho commit.
|
||||||
const [commitTitle, setCommitTitle] = useState("");
|
const [commitTitle, setCommitTitle] = useState("");
|
||||||
// Input note cho commit.
|
|
||||||
const [commitNote, setCommitNote] = useState("");
|
|
||||||
// User ID dùng để gắn vào commit/submit/lock.
|
// User ID dùng để gắn vào commit/submit/lock.
|
||||||
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
|
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
|
||||||
// Section đang mở để edit (null nếu chưa mở).
|
// Project đang mở để edit (null nếu chưa mở).
|
||||||
const [activeSection, setActiveSection] = useState<Section | null>(null);
|
const [activeSection, setActiveSection] = useState<Project | null>(null);
|
||||||
// Trạng thái section (version/head/status/lock).
|
// Trạng thái project (version/head/status/lock).
|
||||||
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
const [projectState, setProjectState] = useState<ProjectState | null>(null);
|
||||||
// Danh sách commits của section đang mở.
|
// Danh sách commits của project đang mở.
|
||||||
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
const [sectionCommits, setProjectCommits] = useState<ProjectCommit[]>([]);
|
||||||
// Baseline snapshot currently loaded for this editor session.
|
// Baseline snapshot currently loaded for this editor session.
|
||||||
const [baselineSnapshot, setBaselineSnapshot] = useState<EditorSnapshot | null>(null);
|
const [baselineSnapshot, setBaselineSnapshot] = useState<EditorSnapshot | null>(null);
|
||||||
|
|
||||||
@@ -63,22 +61,20 @@ export function useSectionSessionState(options: Options) {
|
|||||||
setIsOpeningSection,
|
setIsOpeningSection,
|
||||||
availableSections,
|
availableSections,
|
||||||
setAvailableSections,
|
setAvailableSections,
|
||||||
selectedSectionId,
|
selectedProjectId,
|
||||||
setSelectedSectionId,
|
setSelectedProjectId,
|
||||||
newSectionTitle,
|
newSectionTitle,
|
||||||
setNewSectionTitle,
|
setNewSectionTitle,
|
||||||
commitTitle,
|
commitTitle,
|
||||||
setCommitTitle,
|
setCommitTitle,
|
||||||
commitNote,
|
|
||||||
setCommitNote,
|
|
||||||
editorUserIdInput,
|
editorUserIdInput,
|
||||||
setEditorUserIdInput,
|
setEditorUserIdInput,
|
||||||
activeSection,
|
activeSection,
|
||||||
setActiveSection,
|
setActiveSection,
|
||||||
sectionState,
|
projectState,
|
||||||
setSectionState,
|
setProjectState,
|
||||||
sectionCommits,
|
sectionCommits,
|
||||||
setSectionCommits,
|
setProjectCommits,
|
||||||
baselineSnapshot,
|
baselineSnapshot,
|
||||||
setBaselineSnapshot,
|
setBaselineSnapshot,
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
import { clampYearValue } from "@/uhm/lib/timeline";
|
import { clampYearValue } from "@/uhm/lib/utils/timeline";
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
|
|
||||||
export function useWikiSessionState() {
|
export function useWikiSessionState() {
|
||||||
const [snapshotWikis, setSnapshotWikis] = useState<WikiSnapshot[]>([]);
|
const [snapshotWikis, setSnapshotWikis] = useState<WikiSnapshot[]>([]);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
import { DEFAULT_GEOMETRY_TYPE_ID } from "@/uhm/lib/map/geo/geometryTypeOptions";
|
||||||
import { geoTypeCodeToTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/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";
|
||||||
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
import type { EditorSnapshot, Project } from "@/uhm/types/projects";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
|
|
||||||
type UnknownRecord = Record<string, unknown>;
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -251,7 +263,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildEditorSnapshot(options: {
|
export function buildEditorSnapshot(options: {
|
||||||
section: Section;
|
project: Project;
|
||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
changes: Change[];
|
changes: Change[];
|
||||||
snapshotEntities: EntitySnapshot[];
|
snapshotEntities: EntitySnapshot[];
|
||||||
@@ -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[] = [];
|
||||||
@@ -659,7 +692,7 @@ export function getDefaultTypeIdForFeature(feature: Feature): string {
|
|||||||
if (preset === "line") return "defense_line";
|
if (preset === "line") return "defense_line";
|
||||||
if (preset === "point") return "city";
|
if (preset === "point") return "city";
|
||||||
if (preset === "circle-area") return "war";
|
if (preset === "circle-area") return "war";
|
||||||
if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID;
|
if (preset === "polygon") return DEFAULT_GEOMETRY_TYPE_ID;
|
||||||
|
|
||||||
const geometryType = feature.geometry.type;
|
const geometryType = feature.geometry.type;
|
||||||
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||||
@@ -668,7 +701,7 @@ export function getDefaultTypeIdForFeature(feature: Feature): string {
|
|||||||
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||||
return "city";
|
return "city";
|
||||||
}
|
}
|
||||||
return DEFAULT_ENTITY_TYPE_ID;
|
return DEFAULT_GEOMETRY_TYPE_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeFeatureEntityIds(feature: Feature): string[] {
|
export function normalizeFeatureEntityIds(feature: Feature): string[] {
|
||||||
|
|||||||
+4
-4
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
import { useBackgroundSessionState } from "@/uhm/lib/editor/session/useBackgroundSessionState";
|
import { useBackgroundSessionState } from "@/uhm/lib/editor/session/useBackgroundSessionState";
|
||||||
import { useEntitySessionState } from "@/uhm/lib/editor/session/useEntitySessionState";
|
import { useEntitySessionState } from "@/uhm/lib/editor/session/useEntitySessionState";
|
||||||
import { useSectionSessionState } from "@/uhm/lib/editor/session/useSectionSessionState";
|
import { useProjectSessionState } from "@/uhm/lib/editor/session/useProjectSessionState";
|
||||||
import { useTimelineState } from "@/uhm/lib/editor/session/useTimelineState";
|
import { useTimelineState } from "@/uhm/lib/editor/session/useTimelineState";
|
||||||
import { useWikiSessionState } from "@/uhm/lib/editor/session/useWikiSessionState";
|
import { useWikiSessionState } from "@/uhm/lib/editor/session/useWikiSessionState";
|
||||||
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
@@ -24,10 +24,10 @@ type Options = {
|
|||||||
export function useEditorSessionState(options: Options) {
|
export function useEditorSessionState(options: Options) {
|
||||||
// Mode thao tác map/editor hiện tại.
|
// Mode thao tác map/editor hiện tại.
|
||||||
const [mode, setMode] = useState<EditorMode>("idle");
|
const [mode, setMode] = useState<EditorMode>("idle");
|
||||||
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc section snapshot).
|
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc project snapshot).
|
||||||
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
|
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
|
||||||
|
|
||||||
const section = useSectionSessionState({
|
const project = useProjectSessionState({
|
||||||
defaultEditorUserId: options.defaultEditorUserId,
|
defaultEditorUserId: options.defaultEditorUserId,
|
||||||
});
|
});
|
||||||
const entity = useEntitySessionState();
|
const entity = useEntitySessionState();
|
||||||
@@ -43,7 +43,7 @@ export function useEditorSessionState(options: Options) {
|
|||||||
setMode,
|
setMode,
|
||||||
initialData,
|
initialData,
|
||||||
setInitialData,
|
setInitialData,
|
||||||
...section,
|
...project,
|
||||||
...entity,
|
...entity,
|
||||||
...timeline,
|
...timeline,
|
||||||
...background,
|
...background,
|
||||||
@@ -5,13 +5,13 @@ 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";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/sections";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
|
|
||||||
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||||
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
export 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,5 +1,3 @@
|
|||||||
export const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
|
||||||
export const POINT_ICON_URL = "/point.png";
|
|
||||||
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||||
|
|
||||||
export const MAP_MIN_ZOOM = 2;
|
export const MAP_MIN_ZOOM = 2;
|
||||||
@@ -10,4 +8,5 @@ export const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
|||||||
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
||||||
|
|
||||||
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
||||||
|
export const POLYGON_LABEL_SOURCE_ID = "polygon-labels";
|
||||||
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import type { ModeGetter } from "@/uhm/lib/engine/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,6 +1,7 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
|
||||||
|
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
|
||||||
|
|
||||||
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||||
export function initDrawing(
|
export function initDrawing(
|
||||||
@@ -57,7 +58,13 @@ export function initDrawing(
|
|||||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "draw") return;
|
if (getMode() !== "draw") return;
|
||||||
|
|
||||||
coords.push([e.lngLat.lng, e.lngLat.lat] as [number, number]);
|
let lngLat = e.lngLat;
|
||||||
|
// Dùng Shift (hoặc Alt nếu Shift bị maplibre chiếm dụng) để snap
|
||||||
|
if (e.originalEvent.shiftKey || e.originalEvent.altKey) {
|
||||||
|
lngLat = snapToNearestGeometry(map, e.lngLat, e.point);
|
||||||
|
}
|
||||||
|
|
||||||
|
coords.push([lngLat.lng, lngLat.lat] as [number, number]);
|
||||||
update(coords);
|
update(coords);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +72,14 @@ export function initDrawing(
|
|||||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "draw" || coords.length === 0) return;
|
if (getMode() !== "draw" || coords.length === 0) return;
|
||||||
|
|
||||||
|
let lngLat = e.lngLat;
|
||||||
|
if (e.originalEvent.shiftKey || e.originalEvent.altKey) {
|
||||||
|
lngLat = snapToNearestGeometry(map, e.lngLat, e.point);
|
||||||
|
}
|
||||||
|
|
||||||
const preview: [number, number][] = [
|
const preview: [number, number][] = [
|
||||||
...coords,
|
...coords,
|
||||||
[e.lngLat.lng, e.lngLat.lat] as [number, number],
|
[lngLat.lng, lngLat.lat] as [number, number],
|
||||||
];
|
];
|
||||||
update(preview);
|
update(preview);
|
||||||
}
|
}
|
||||||
@@ -109,11 +121,17 @@ export function initDrawing(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tắt tính năng box zoom và double click zoom để Shift không bị lỗi
|
||||||
|
map.boxZoom.disable();
|
||||||
|
map.doubleClickZoom.disable();
|
||||||
|
|
||||||
map.on("click", onClick);
|
map.on("click", onClick);
|
||||||
map.on("mousemove", onMove);
|
map.on("mousemove", onMove);
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
map.boxZoom.enable();
|
||||||
|
map.doubleClickZoom.enable();
|
||||||
map.off("click", onClick);
|
map.off("click", onClick);
|
||||||
map.off("mousemove", onMove);
|
map.off("mousemove", onMove);
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/uhm/lib/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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
|
||||||
|
|
||||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
|
||||||
|
|
||||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/uhm/lib/useEditorState";
|
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
|
||||||
|
|
||||||
// Khởi tạo engine thêm point bằng click đơn.
|
// Khởi tạo engine thêm point bằng click đơn.
|
||||||
export function initPoint(
|
export function initPoint(
|
||||||
+28
-20
@@ -1,5 +1,5 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
|
||||||
|
|
||||||
// Khởi tạo engine chọn feature và context menu edit/delete.
|
// Khởi tạo engine chọn feature và context menu edit/delete.
|
||||||
export function initSelect(
|
export function initSelect(
|
||||||
@@ -7,25 +7,17 @@ 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,
|
||||||
onSelectId?: (id: string | number | null) => void
|
onSelectIds?: (ids: (string | number)[]) => void,
|
||||||
|
onReplayEdit?: (id: string | number) => void
|
||||||
) {
|
) {
|
||||||
const SELECTABLE_LAYERS = [
|
|
||||||
"countries-fill",
|
|
||||||
"countries-line",
|
|
||||||
"routes-line",
|
|
||||||
"routes-path-arrow-fill",
|
|
||||||
"routes-path-arrow-line",
|
|
||||||
"routes-path-hit",
|
|
||||||
"places-circle",
|
|
||||||
"places-symbol",
|
|
||||||
] as const;
|
|
||||||
const FEATURE_STATE_SOURCES = [
|
const FEATURE_STATE_SOURCES = [
|
||||||
"countries",
|
"countries",
|
||||||
"places",
|
"places",
|
||||||
"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;
|
||||||
|
|
||||||
@@ -35,7 +27,7 @@ export function initSelect(
|
|||||||
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
||||||
selectedIds.clear();
|
selectedIds.clear();
|
||||||
if (emit) {
|
if (emit) {
|
||||||
onSelectId?.(null);
|
onSelectIds?.([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,18 +44,18 @@ export function initSelect(
|
|||||||
// Alt + click on an already selected feature removes it from the selection
|
// Alt + click on an already selected feature removes it from the selection
|
||||||
setSelectionStateForId(id, false);
|
setSelectionStateForId(id, false);
|
||||||
selectedIds.delete(id);
|
selectedIds.delete(id);
|
||||||
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null);
|
onSelectIds?.(Array.from(selectedIds));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectionStateForId(id, true);
|
setSelectionStateForId(id, true);
|
||||||
selectedIds.add(id);
|
selectedIds.add(id);
|
||||||
onSelectId?.(selectedIds.size === 1 ? id : null);
|
onSelectIds?.(Array.from(selectedIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
@@ -83,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,
|
||||||
@@ -114,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 = "";
|
||||||
@@ -129,7 +122,11 @@ export function initSelect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSelectableLayers(): string[] {
|
function getSelectableLayers(): string[] {
|
||||||
return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId)));
|
const style = map.getStyle();
|
||||||
|
if (!style || !style.layers) return [];
|
||||||
|
return style.layers
|
||||||
|
.filter((layer) => "source" in layer && FEATURE_STATE_SOURCES.includes(layer.source as any))
|
||||||
|
.map((layer) => layer.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSelectionStateForId(id: string | number, selected: boolean) {
|
function setSelectionStateForId(id: string | number, selected: boolean) {
|
||||||
@@ -223,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,82 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
|
||||||
|
const SNAP_THRESHOLD_PX = 15;
|
||||||
|
|
||||||
|
export function snapToNearestGeometry(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
lngLat: maplibregl.LngLat,
|
||||||
|
pointPx: maplibregl.Point
|
||||||
|
): maplibregl.LngLat {
|
||||||
|
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||||
|
[pointPx.x - SNAP_THRESHOLD_PX, pointPx.y - SNAP_THRESHOLD_PX],
|
||||||
|
[pointPx.x + SNAP_THRESHOLD_PX, pointPx.y + SNAP_THRESHOLD_PX],
|
||||||
|
];
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(bbox);
|
||||||
|
|
||||||
|
let nearestDist = Infinity;
|
||||||
|
let nearestLngLat: maplibregl.LngLat | null = null;
|
||||||
|
|
||||||
|
const getDistSq = (p1: maplibregl.Point, p2: maplibregl.Point) => {
|
||||||
|
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tìm điểm gần nhất trên đoạn thẳng [a, b] so với điểm p
|
||||||
|
const getClosestPointOnSegment = (p: maplibregl.Point, a: maplibregl.Point, b: maplibregl.Point): maplibregl.Point => {
|
||||||
|
const atob = { x: b.x - a.x, y: b.y - a.y };
|
||||||
|
const atop = { x: p.x - a.x, y: p.y - a.y };
|
||||||
|
const lenSq = atob.x * atob.x + atob.y * atob.y;
|
||||||
|
if (lenSq === 0) return new maplibregl.Point(a.x, a.y);
|
||||||
|
|
||||||
|
let t = (atop.x * atob.x + atop.y * atob.y) / lenSq;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
return new maplibregl.Point(a.x + atob.x * t, a.y + atob.y * t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processLineString = (line: number[][]) => {
|
||||||
|
if (!line || line.length < 2) return;
|
||||||
|
for (let i = 0; i < line.length - 1; i++) {
|
||||||
|
const p1LngLat = new maplibregl.LngLat(line[i][0], line[i][1]);
|
||||||
|
const p2LngLat = new maplibregl.LngLat(line[i + 1][0], line[i + 1][1]);
|
||||||
|
const p1 = map.project(p1LngLat);
|
||||||
|
const p2 = map.project(p2LngLat);
|
||||||
|
|
||||||
|
const closestPx = getClosestPointOnSegment(pointPx, p1, p2);
|
||||||
|
const distSq = getDistSq(pointPx, closestPx);
|
||||||
|
|
||||||
|
if (distSq < nearestDist && distSq <= SNAP_THRESHOLD_PX ** 2) {
|
||||||
|
nearestDist = distSq;
|
||||||
|
nearestLngLat = map.unproject(closestPx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
if (!feature.geometry) continue;
|
||||||
|
|
||||||
|
// Bỏ qua các layer preview hoặc edit để không tự snap vào nét đang vẽ dở.
|
||||||
|
if (feature.layer.id.includes("preview") || feature.layer.id.includes("edit-")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = feature.geometry.type;
|
||||||
|
if (type === "GeometryCollection") continue;
|
||||||
|
const coords = (feature.geometry as any).coordinates;
|
||||||
|
|
||||||
|
// Xử lý cả Polygon và LineString vì viền bản đồ (border) đôi khi được render dưới dạng LineString
|
||||||
|
if (type === "Polygon") {
|
||||||
|
for (const ring of coords) processLineString(ring);
|
||||||
|
} else if (type === "MultiPolygon") {
|
||||||
|
for (const poly of coords) {
|
||||||
|
for (const ring of poly) processLineString(ring);
|
||||||
|
}
|
||||||
|
} else if (type === "LineString") {
|
||||||
|
processLineString(coords);
|
||||||
|
} else if (type === "MultiLineString") {
|
||||||
|
for (const line of coords) processLineString(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearestLngLat || lngLat;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import rows from "@/uhm/lib/geoTypeMap.json";
|
import rows from "@/uhm/lib/map/geo/geoTypeMap.json";
|
||||||
|
|
||||||
export type GeoTypeMapRow = {
|
export type GeoTypeMapRow = {
|
||||||
type_key: string;
|
type_key: string;
|
||||||
@@ -7,6 +7,14 @@ export type GeoTypeMapRow = {
|
|||||||
|
|
||||||
const MAP_ROWS: GeoTypeMapRow[] = rows as GeoTypeMapRow[];
|
const MAP_ROWS: GeoTypeMapRow[] = rows as GeoTypeMapRow[];
|
||||||
|
|
||||||
|
export const GEO_TYPE_KEYS: string[] = Array.from(
|
||||||
|
new Set(
|
||||||
|
MAP_ROWS
|
||||||
|
.map((row) => (typeof row?.type_key === "string" ? row.type_key.trim().toLowerCase() : ""))
|
||||||
|
.filter((key) => key.length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const CODE_BY_KEY = new Map<string, number>();
|
const CODE_BY_KEY = new Map<string, number>();
|
||||||
const KEY_BY_CODE = new Map<number, string>();
|
const KEY_BY_CODE = new Map<number, string>();
|
||||||
|
|
||||||
@@ -32,3 +40,19 @@ export function geoTypeCodeToTypeKey(code: number | null | undefined): string |
|
|||||||
return KEY_BY_CODE.get(Math.trunc(code)) ?? null;
|
return KEY_BY_CODE.get(Math.trunc(code)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeGeoTypeKey(value: unknown): string | null {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return geoTypeCodeToTypeKey(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized.length) return null;
|
||||||
|
|
||||||
|
if (/^-?\d+$/.test(normalized)) {
|
||||||
|
return geoTypeCodeToTypeKey(Number(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
export type EntityTypeGroupId =
|
export type GeometryTypeGroupId =
|
||||||
| "line"
|
| "line"
|
||||||
| "polygon"
|
| "polygon"
|
||||||
| "circle"
|
| "circle"
|
||||||
| "point";
|
| "point";
|
||||||
|
|
||||||
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
export type GeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||||
|
|
||||||
export type EntityTypeGroup = {
|
export type GeometryTypeGroup = {
|
||||||
id: EntityTypeGroupId;
|
id: GeometryTypeGroupId;
|
||||||
label: string;
|
label: string;
|
||||||
geometryLabel: string;
|
geometryLabel: string;
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EntityTypeOption = {
|
export type GeometryTypeOption = {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
groupId: EntityTypeGroupId;
|
groupId: GeometryTypeGroupId;
|
||||||
groupLabel: string;
|
groupLabel: string;
|
||||||
geometryPreset: EntityGeometryPreset;
|
geometryPreset: GeometryPreset;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
export const GEOMETRY_TYPE_GROUPS: GeometryTypeGroup[] = [
|
||||||
{
|
{
|
||||||
id: "line",
|
id: "line",
|
||||||
label: "line - Tuyến",
|
label: "line - Tuyến",
|
||||||
@@ -48,18 +48,18 @@ export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
|
const GROUP_BY_ID: Record<GeometryTypeGroupId, GeometryTypeGroup> = {
|
||||||
line: ENTITY_TYPE_GROUPS[0],
|
line: GEOMETRY_TYPE_GROUPS[0],
|
||||||
polygon: ENTITY_TYPE_GROUPS[1],
|
polygon: GEOMETRY_TYPE_GROUPS[1],
|
||||||
circle: ENTITY_TYPE_GROUPS[2],
|
circle: GEOMETRY_TYPE_GROUPS[2],
|
||||||
point: ENTITY_TYPE_GROUPS[3],
|
point: GEOMETRY_TYPE_GROUPS[3],
|
||||||
};
|
};
|
||||||
|
|
||||||
const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
const RAW_GEOMETRY_TYPE_OPTIONS: Array<{
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
groupId: EntityTypeGroupId;
|
groupId: GeometryTypeGroupId;
|
||||||
geometryPreset: EntityGeometryPreset;
|
geometryPreset: GeometryPreset;
|
||||||
}> = [
|
}> = [
|
||||||
{ value: "defense_line", label: "Defense Line", groupId: "line", geometryPreset: "line" },
|
{ value: "defense_line", label: "Defense Line", groupId: "line", geometryPreset: "line" },
|
||||||
|
|
||||||
@@ -94,29 +94,29 @@ const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
|||||||
{ value: "bridge", label: "Bridge", groupId: "point", geometryPreset: "point" },
|
{ value: "bridge", label: "Bridge", groupId: "point", geometryPreset: "point" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.map((item) => ({
|
export const GEOMETRY_TYPE_OPTIONS: GeometryTypeOption[] = RAW_GEOMETRY_TYPE_OPTIONS.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
groupLabel: GROUP_BY_ID[item.groupId].label,
|
groupLabel: GROUP_BY_ID[item.groupId].label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const DEFAULT_ENTITY_TYPE_ID = "country";
|
export const DEFAULT_GEOMETRY_TYPE_ID = "country";
|
||||||
|
|
||||||
// Gom option theo group để render select phân nhóm.
|
// Gom option theo group để render select phân nhóm.
|
||||||
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
|
export function groupGeometryTypeOptions(options: GeometryTypeOption[] = GEOMETRY_TYPE_OPTIONS): Array<{
|
||||||
id: EntityTypeGroupId;
|
id: GeometryTypeGroupId;
|
||||||
label: string;
|
label: string;
|
||||||
geometryLabel: string;
|
geometryLabel: string;
|
||||||
description: string;
|
description: string;
|
||||||
options: EntityTypeOption[];
|
options: GeometryTypeOption[];
|
||||||
}> {
|
}> {
|
||||||
return ENTITY_TYPE_GROUPS.map((group) => ({
|
return GEOMETRY_TYPE_GROUPS.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
options: options.filter((option) => option.groupId === group.id),
|
options: options.filter((option) => option.groupId === group.id),
|
||||||
})).filter((group) => group.options.length > 0);
|
})).filter((group) => group.options.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tìm option theo type id, trả null nếu không tồn tại.
|
// Tìm option theo type id, trả null nếu không tồn tại.
|
||||||
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
|
export function findGeometryTypeOption(typeId: string | null | undefined): GeometryTypeOption | null {
|
||||||
if (!typeId) return null;
|
if (!typeId) return null;
|
||||||
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
return GEOMETRY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
export const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
|
||||||
|
export { ensurePointGeotypeIcons } from "./shared/pointStyle";
|
||||||
|
|
||||||
|
import { getDefenseLineLayers } from "./geotypes/defense_line";
|
||||||
|
import { getAttackRouteLayers } from "./geotypes/attack_route";
|
||||||
|
import { getRetreatRouteLayers } from "./geotypes/retreat_route";
|
||||||
|
import { getInvasionRouteLayers } from "./geotypes/invasion_route";
|
||||||
|
import { getMigrationRouteLayers } from "./geotypes/migration_route";
|
||||||
|
import { getRefugeeRouteLayers } from "./geotypes/refugee_route";
|
||||||
|
import { getTradeRouteLayers } from "./geotypes/trade_route";
|
||||||
|
import { getShippingRouteLayers } from "./geotypes/shipping_route";
|
||||||
|
import { getCountryLayers } from "./geotypes/country";
|
||||||
|
import { getStateLayers } from "./geotypes/state";
|
||||||
|
import { getEmpireLayers } from "./geotypes/empire";
|
||||||
|
import { getKingdomLayers } from "./geotypes/kingdom";
|
||||||
|
import { getWarLayers } from "./geotypes/war";
|
||||||
|
import { getBattleLayers } from "./geotypes/battle";
|
||||||
|
import { getCivilizationLayers } from "./geotypes/civilization";
|
||||||
|
import { getRebellionZoneLayers } from "./geotypes/rebellion_zone";
|
||||||
|
import { getPersonDeathplaceLayers } from "./geotypes/person_deathplace";
|
||||||
|
import { getPersonBirthplaceLayers } from "./geotypes/person_birthplace";
|
||||||
|
import { getPersonActivityLayers } from "./geotypes/person_activity";
|
||||||
|
import { getTempleLayers } from "./geotypes/temple";
|
||||||
|
import { getCapitalLayers } from "./geotypes/capital";
|
||||||
|
import { getCityLayers } from "./geotypes/city";
|
||||||
|
import { getFortressLayers } from "./geotypes/fortress";
|
||||||
|
import { getCastleLayers } from "./geotypes/castle";
|
||||||
|
import { getRuinLayers } from "./geotypes/ruin";
|
||||||
|
import { getPortLayers } from "./geotypes/port";
|
||||||
|
import { getBridgeLayers } from "./geotypes/bridge";
|
||||||
|
import { getLineLabelLayers } from "./shared/lineLabels";
|
||||||
|
import { getPolygonLabelLayers } from "./shared/polygonLabels";
|
||||||
|
|
||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
|
||||||
|
export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
return [
|
||||||
|
...getDefenseLineLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getAttackRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getRetreatRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getInvasionRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getMigrationRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getRefugeeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getTradeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getShippingRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getCountryLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getStateLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getKingdomLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getWarLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getCivilizationLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getRebellionZoneLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getPersonDeathplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getPersonBirthplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getPersonActivityLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getTempleLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getCapitalLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getCityLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getFortressLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getCastleLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getRuinLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getPortLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getBridgeLayers(sourceId, pathArrowSourceId, pointSourceId)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllGeotypeLabelLayers(polygonLabelSourceId: string, lineSourceId: string): LayerSpecification[] {
|
||||||
|
return [
|
||||||
|
...getPolygonLabelLayers(polygonLabelSourceId),
|
||||||
|
...getLineLabelLayers(lineSourceId),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
|
||||||
|
|
||||||
|
export function getAttackRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void pointSourceId;
|
||||||
|
return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
|
||||||
|
typeId: "attack_route",
|
||||||
|
color: "#ef4444",
|
||||||
|
strokeColor: "#7f1d1d",
|
||||||
|
width: { z1: 2.6, z4: 3.8, z6: 5 },
|
||||||
|
arrowOpacity: 0.9,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
|
||||||
|
|
||||||
|
export function getBattleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void pathArrowSourceId;
|
||||||
|
void pointSourceId;
|
||||||
|
return buildPolygonGeotypeLayers(sourceId, {
|
||||||
|
typeId: "battle",
|
||||||
|
fillColor: "#f43f5e",
|
||||||
|
strokeColor: "#9f1239",
|
||||||
|
fillOpacity: 0.3,
|
||||||
|
strokeWidth: { z1: 1.5, z4: 2.2, z6: 3 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPointGeotypeLayers } from "../shared/pointStyle";
|
||||||
|
|
||||||
|
export function getBridgeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void sourceId;
|
||||||
|
void pathArrowSourceId;
|
||||||
|
return buildPointGeotypeLayers("bridge", pointSourceId!);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPointGeotypeLayers } from "../shared/pointStyle";
|
||||||
|
|
||||||
|
export function getCapitalLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void sourceId;
|
||||||
|
void pathArrowSourceId;
|
||||||
|
return buildPointGeotypeLayers("capital", pointSourceId!);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPointGeotypeLayers } from "../shared/pointStyle";
|
||||||
|
|
||||||
|
export function getCastleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void sourceId;
|
||||||
|
void pathArrowSourceId;
|
||||||
|
return buildPointGeotypeLayers("castle", pointSourceId!);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPointGeotypeLayers } from "../shared/pointStyle";
|
||||||
|
|
||||||
|
export function getCityLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void sourceId;
|
||||||
|
void pathArrowSourceId;
|
||||||
|
return buildPointGeotypeLayers("city", pointSourceId!);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
|
||||||
|
|
||||||
|
export function getCivilizationLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void pathArrowSourceId;
|
||||||
|
void pointSourceId;
|
||||||
|
return buildPolygonGeotypeLayers(sourceId, {
|
||||||
|
typeId: "civilization",
|
||||||
|
fillColor: "#14b8a6",
|
||||||
|
strokeColor: "#134e4a",
|
||||||
|
fillOpacity: 0.34,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
|
||||||
|
|
||||||
|
export function getCountryLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void pathArrowSourceId;
|
||||||
|
void pointSourceId;
|
||||||
|
return buildPolygonGeotypeLayers(sourceId, {
|
||||||
|
typeId: "country",
|
||||||
|
fillColor: "#2563eb",
|
||||||
|
strokeColor: "#1e40af",
|
||||||
|
fillOpacity: 0.34,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
|
||||||
|
|
||||||
|
export function getDefenseLineLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void pointSourceId;
|
||||||
|
return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
|
||||||
|
typeId: "defense_line",
|
||||||
|
color: "#38bdf8",
|
||||||
|
strokeColor: "#075985",
|
||||||
|
dasharray: [3, 2],
|
||||||
|
arrow: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
|
||||||
|
|
||||||
|
export function getEmpireLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void pathArrowSourceId;
|
||||||
|
void pointSourceId;
|
||||||
|
return buildPolygonGeotypeLayers(sourceId, {
|
||||||
|
typeId: "empire",
|
||||||
|
fillColor: "#f59e0b",
|
||||||
|
strokeColor: "#92400e",
|
||||||
|
fillOpacity: 0.36,
|
||||||
|
strokeWidth: { z1: 1.8, z4: 2.6, z6: 3.4 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPointGeotypeLayers } from "../shared/pointStyle";
|
||||||
|
|
||||||
|
export function getFortressLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void sourceId;
|
||||||
|
void pathArrowSourceId;
|
||||||
|
return buildPointGeotypeLayers("fortress", pointSourceId!);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
|
||||||
|
|
||||||
|
export function getInvasionRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void pointSourceId;
|
||||||
|
return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
|
||||||
|
typeId: "invasion_route",
|
||||||
|
color: "#be123c",
|
||||||
|
strokeColor: "#4c0519",
|
||||||
|
width: { z1: 2.8, z4: 4.1, z6: 5.4 },
|
||||||
|
arrowOpacity: 0.9,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
|
||||||
|
|
||||||
|
export function getKingdomLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void pathArrowSourceId;
|
||||||
|
void pointSourceId;
|
||||||
|
return buildPolygonGeotypeLayers(sourceId, {
|
||||||
|
typeId: "kingdom",
|
||||||
|
fillColor: "#8b5cf6",
|
||||||
|
strokeColor: "#6d28d9",
|
||||||
|
fillOpacity: 0.34,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
|
||||||
|
|
||||||
|
export function getMigrationRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void pointSourceId;
|
||||||
|
return buildLineGeotypeLayers(sourceId, pathArrowSourceId, {
|
||||||
|
typeId: "migration_route",
|
||||||
|
color: "#10b981",
|
||||||
|
strokeColor: "#065f46",
|
||||||
|
dasharray: [4, 3],
|
||||||
|
arrowOpacity: 0.76,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPointGeotypeLayers } from "../shared/pointStyle";
|
||||||
|
|
||||||
|
export function getPersonActivityLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void sourceId;
|
||||||
|
void pathArrowSourceId;
|
||||||
|
return buildPointGeotypeLayers("person_activity", pointSourceId!);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { LayerSpecification } from "maplibre-gl";
|
||||||
|
import { buildPointGeotypeLayers } from "../shared/pointStyle";
|
||||||
|
|
||||||
|
export function getPersonBirthplaceLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
|
void sourceId;
|
||||||
|
void pathArrowSourceId;
|
||||||
|
return buildPointGeotypeLayers("person_birthplace", pointSourceId!);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user