add editor
This commit is contained in:
8
api.ts
8
api.ts
@@ -58,4 +58,10 @@ export const API = {
|
|||||||
GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
|
GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
|
||||||
RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`,
|
RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`,
|
||||||
},
|
},
|
||||||
}
|
Submission: {
|
||||||
|
SEARCH: `${API_URL_ROOT}/submissions`,
|
||||||
|
GET_BY_ID: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`,
|
||||||
|
UPDATE_STATUS: (id: number | string) => `${API_URL_ROOT}/submissions/${id}/status`,
|
||||||
|
DELETE: (id: number | string) => `${API_URL_ROOT}/submissions/${id}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
508
package-lock.json
generated
508
package-lock.json
generated
@@ -23,9 +23,11 @@
|
|||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
|
"maplibre-gl": "^5.20.2",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-apexcharts": "^1.8.0",
|
"react-apexcharts": "^1.8.0",
|
||||||
|
"react-d3-tree": "^3.6.6",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -95,6 +97,7 @@
|
|||||||
"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",
|
||||||
@@ -1765,6 +1768,24 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bkrem/react-transition-group": {
|
||||||
|
"version": "1.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bkrem/react-transition-group/-/react-transition-group-1.3.5.tgz",
|
||||||
|
"integrity": "sha512-lbBYhC42sxAeFEopxzd9oWdkkV0zirO5E9WyeOBxOrpXsf7m30Aj8vnbayZxFOwD9pvUQ2Pheb1gO79s0Qap3Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"chain-function": "^1.0.0",
|
||||||
|
"dom-helpers": "^3.3.1",
|
||||||
|
"loose-envify": "^1.3.1",
|
||||||
|
"prop-types": "^15.5.6",
|
||||||
|
"react-lifecycles-compat": "^3.0.4",
|
||||||
|
"warning": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||||
@@ -1945,6 +1966,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -2562,6 +2584,111 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/point-geometry": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/tiny-sdf": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/unitbezier": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/vector-tile": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "~1.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"pbf": "^4.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/whoots-js": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||||
|
"version": "24.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.1.tgz",
|
||||||
|
"integrity": "sha512-zxa92qF96ZNojLxeAjnaRpjVCy+swoUNJvDhtpC90k7u5F0TMr4GmvNqMKvYrMoPB8d7gRSXbMG1hBbmgESIsw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||||
|
"@mapbox/unitbezier": "^0.0.1",
|
||||||
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"rw": "^1.3.3",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"gl-style-format": "dist/gl-style-format.mjs",
|
||||||
|
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||||
|
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/mlt": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==",
|
||||||
|
"license": "(MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/vector-tile": "^2.0.4",
|
||||||
|
"@maplibre/geojson-vt": "^5.0.4",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
|
"pbf": "^4.0.1",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@@ -2894,6 +3021,7 @@
|
|||||||
"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"
|
||||||
@@ -2917,6 +3045,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -3093,6 +3222,7 @@
|
|||||||
"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",
|
||||||
@@ -3482,6 +3612,12 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-hierarchy": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -3489,6 +3625,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -3509,6 +3651,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -3519,6 +3662,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -3529,6 +3673,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -3543,6 +3688,15 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/supercluster": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@@ -3594,6 +3748,7 @@
|
|||||||
"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",
|
||||||
@@ -4125,6 +4280,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -4180,6 +4336,7 @@
|
|||||||
"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",
|
||||||
@@ -4592,6 +4749,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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",
|
||||||
@@ -4698,6 +4856,12 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/chain-function": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chain-function/-/chain-function-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-SxltgMwL9uCko5/ZCLiyG2B7R9fY4pDZUw7hJ4MhirdjBLosoDqkWABi3XMucddHdLiFJMb7PD2MZifZriuMTg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -4727,6 +4891,15 @@
|
|||||||
"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",
|
||||||
|
"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",
|
||||||
@@ -4926,6 +5099,133 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-hierarchy": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "1.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
|
||||||
|
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -5067,6 +5367,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -5109,6 +5418,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-helpers": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
@@ -5193,6 +5511,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/earcut": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.329",
|
"version": "1.5.329",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz",
|
||||||
@@ -5444,6 +5768,7 @@
|
|||||||
"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",
|
||||||
@@ -5629,6 +5954,7 @@
|
|||||||
"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",
|
||||||
@@ -6215,6 +6541,12 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gl-matrix": {
|
||||||
|
"version": "3.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||||
|
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -6922,7 +7254,8 @@
|
|||||||
"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",
|
||||||
@@ -6984,6 +7317,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stringify-pretty-compact": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -7013,6 +7352,12 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -7403,6 +7748,40 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/maplibre-gl": {
|
||||||
|
"version": "5.24.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
|
||||||
|
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/tiny-sdf": "^2.1.0",
|
||||||
|
"@mapbox/unitbezier": "^0.0.1",
|
||||||
|
"@mapbox/vector-tile": "^2.0.4",
|
||||||
|
"@mapbox/whoots-js": "^3.1.0",
|
||||||
|
"@maplibre/geojson-vt": "^6.1.0",
|
||||||
|
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
|
||||||
|
"@maplibre/mlt": "^1.1.8",
|
||||||
|
"@maplibre/vt-pbf": "^4.3.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"earcut": "^3.0.2",
|
||||||
|
"gl-matrix": "^3.4.4",
|
||||||
|
"kdbush": "^4.0.2",
|
||||||
|
"murmurhash-js": "^1.0.0",
|
||||||
|
"pbf": "^4.0.1",
|
||||||
|
"potpack": "^2.1.0",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14.0",
|
||||||
|
"npm": ">=8.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -7490,7 +7869,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -7503,6 +7881,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/murmurhash-js": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -7939,6 +8323,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pbf": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pbf": "bin/pbf"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -7987,6 +8383,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -8002,6 +8399,12 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/potpack": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.12.1",
|
"version": "10.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||||
@@ -8033,6 +8436,12 @@
|
|||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/protocol-buffers-schema": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@@ -8073,6 +8482,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quickselect": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/quill": {
|
"node_modules/quill": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||||
@@ -8093,6 +8508,7 @@
|
|||||||
"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",
|
||||||
@@ -8107,6 +8523,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -8124,6 +8541,27 @@
|
|||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-d3-tree": {
|
||||||
|
"version": "3.6.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-d3-tree/-/react-d3-tree-3.6.6.tgz",
|
||||||
|
"integrity": "sha512-E9ByUdeqvlxLlF9BSL7KWQH3ikYHtHO+g1rAPcVgj6mu92tjRUCan2AWxoD4eTSzzAATf8BZtf+CXGSoSd6ioQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bkrem/react-transition-group": "^1.3.5",
|
||||||
|
"@types/d3-hierarchy": "^1.1.8",
|
||||||
|
"clone": "^2.1.1",
|
||||||
|
"d3-hierarchy": "^1.1.9",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-shape": "^1.3.7",
|
||||||
|
"d3-zoom": "^3.0.0",
|
||||||
|
"dequal": "^2.0.2",
|
||||||
|
"uuid": "^8.3.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "16.x || 17.x || 18.x || 19.x",
|
||||||
|
"react-dom": "16.x || 17.x || 18.x || 19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dnd": {
|
"node_modules/react-dnd": {
|
||||||
"version": "16.0.1",
|
"version": "16.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
||||||
@@ -8168,6 +8606,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -8198,6 +8637,12 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-lifecycles-compat": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-quill-new": {
|
"node_modules/react-quill-new": {
|
||||||
"version": "3.8.3",
|
"version": "3.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
||||||
@@ -8218,6 +8663,7 @@
|
|||||||
"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"
|
||||||
@@ -8240,7 +8686,8 @@
|
|||||||
"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",
|
||||||
@@ -8400,6 +8847,15 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-protobuf-schema": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reusify": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -8435,6 +8891,12 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rw": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/safe-array-concat": {
|
"node_modules/safe-array-concat": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||||
@@ -8932,6 +9394,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supercluster": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -9034,7 +9505,8 @@
|
|||||||
"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",
|
||||||
@@ -9090,6 +9562,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -9097,6 +9570,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyqueue": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -9252,6 +9731,7 @@
|
|||||||
"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"
|
||||||
@@ -9438,6 +9918,25 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/warning": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -9605,6 +10104,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,11 @@
|
|||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
|
"maplibre-gl": "^5.20.2",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-apexcharts": "^1.8.0",
|
"react-apexcharts": "^1.8.0",
|
||||||
|
"react-d3-tree": "^3.6.6",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
BIN
public/point.png
Normal file
BIN
public/point.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
114
src/app/editor/[id]/featureCommands.ts
Normal file
114
src/app/editor/[id]/featureCommands.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
|
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||||
|
import { ApiError } from "@/uhm/api/http";
|
||||||
|
import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
|
||||||
|
import { buildGeometryMetadataPatch } from "@/uhm/lib/editor/geometry/geometryMetadata";
|
||||||
|
import { uniqueEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
type EditorDraftApi = {
|
||||||
|
patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial<FeatureProperties>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
editor: EditorDraftApi;
|
||||||
|
selectedFeature: Feature | null;
|
||||||
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
|
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
|
||||||
|
selectedGeometryEntityIds: string[];
|
||||||
|
setSelectedGeometryEntityIds: Dispatch<SetStateAction<string[]>>;
|
||||||
|
entities: Entity[];
|
||||||
|
setIsEntitySubmitting: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFeatureCommands(options: Options) {
|
||||||
|
const {
|
||||||
|
editor,
|
||||||
|
selectedFeature,
|
||||||
|
geometryMetaForm,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
entities,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
setEntityFormStatus,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const applyGeometryMetadata = useCallback(async () => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = buildGeometryMetadataPatch(geometryMetaForm);
|
||||||
|
} catch (err) {
|
||||||
|
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
try {
|
||||||
|
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
|
||||||
|
setGeometryMetaForm(metadata.formState);
|
||||||
|
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
|
||||||
|
} finally {
|
||||||
|
setIsEntitySubmitting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
editor,
|
||||||
|
geometryMetaForm,
|
||||||
|
selectedFeature,
|
||||||
|
setEntityFormStatus,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const applyEntitiesToSelectedGeometry = useCallback(async () => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
try {
|
||||||
|
editor.patchFeatureProperties(
|
||||||
|
selectedFeature.properties.id,
|
||||||
|
buildFeatureEntityPatch(selectedFeature, entityIds, entities)
|
||||||
|
);
|
||||||
|
setSelectedGeometryEntityIds(entityIds);
|
||||||
|
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
setEntityFormStatus("Lưu thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsEntitySubmitting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
editor,
|
||||||
|
entities,
|
||||||
|
selectedFeature,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
setEntityFormStatus,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyGeometryMetadata,
|
||||||
|
applyEntitiesToSelectedGeometry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
794
src/app/editor/[id]/page.tsx
Normal file
794
src/app/editor/[id]/page.tsx
Normal file
@@ -0,0 +1,794 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import Map from "@/uhm/components/Map";
|
||||||
|
import Editor from "@/uhm/components/Editor";
|
||||||
|
import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel";
|
||||||
|
import TimelineBar from "@/uhm/components/TimelineBar";
|
||||||
|
import SelectedGeometryPanel from "@/uhm/components/SelectedGeometryPanel";
|
||||||
|
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
|
||||||
|
import { ApiError } from "@/uhm/api/http";
|
||||||
|
import { fetchCurrentUser } from "@/uhm/api/auth";
|
||||||
|
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
|
||||||
|
import { SectionCommit } from "@/uhm/api/sections";
|
||||||
|
import {
|
||||||
|
Feature,
|
||||||
|
useEditorState,
|
||||||
|
} from "@/uhm/lib/useEditorState";
|
||||||
|
import {
|
||||||
|
BackgroundLayerId,
|
||||||
|
BackgroundLayerVisibility,
|
||||||
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
} from "@/uhm/lib/backgroundLayers";
|
||||||
|
import {
|
||||||
|
DEFAULT_ENTITY_TYPE_ID,
|
||||||
|
ENTITY_TYPE_OPTIONS,
|
||||||
|
EntityTypeGroupId,
|
||||||
|
findEntityTypeOption,
|
||||||
|
} from "@/uhm/lib/entityTypeOptions";
|
||||||
|
import {
|
||||||
|
EntityFormState,
|
||||||
|
PendingEntityCreate,
|
||||||
|
useEditorSessionState,
|
||||||
|
} from "@/uhm/lib/useEditorSessionState";
|
||||||
|
import {
|
||||||
|
getDefaultTypeIdForFeature,
|
||||||
|
normalizeFeatureBindingIds,
|
||||||
|
normalizeFeatureEntityIds,
|
||||||
|
uniqueEntityIds,
|
||||||
|
} from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
import {
|
||||||
|
buildClientEntityId,
|
||||||
|
formatEntityNamesForDisplay,
|
||||||
|
mergeEntitiesWithPending,
|
||||||
|
mergeEntitySearchResults,
|
||||||
|
} from "@/uhm/lib/editor/entity/entityBinding";
|
||||||
|
import {
|
||||||
|
formatBindingIdsForDisplay,
|
||||||
|
} from "@/uhm/lib/editor/geometry/geometryMetadata";
|
||||||
|
import {
|
||||||
|
loadBackgroundLayerVisibilityFromStorage,
|
||||||
|
persistBackgroundLayerVisibility,
|
||||||
|
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||||
|
import { useSectionCommands } from "@/uhm/lib/editor/section/useSectionCommands";
|
||||||
|
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants";
|
||||||
|
import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline";
|
||||||
|
import { useFeatureCommands } from "./featureCommands";
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||||
|
const DEFAULT_EDITOR_USER_ID = "local-editor";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const projectId = String(params.id || "");
|
||||||
|
const openedProjectIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
initialData,
|
||||||
|
setInitialData,
|
||||||
|
isSaving,
|
||||||
|
setIsSaving,
|
||||||
|
isSubmitting,
|
||||||
|
setIsSubmitting,
|
||||||
|
isOpeningSection,
|
||||||
|
setIsOpeningSection,
|
||||||
|
availableSections,
|
||||||
|
setAvailableSections,
|
||||||
|
selectedSectionId,
|
||||||
|
setSelectedSectionId,
|
||||||
|
newSectionTitle,
|
||||||
|
setNewSectionTitle,
|
||||||
|
commitTitle,
|
||||||
|
setCommitTitle,
|
||||||
|
commitNote,
|
||||||
|
setCommitNote,
|
||||||
|
editorUserIdInput,
|
||||||
|
setEditorUserIdInput,
|
||||||
|
activeSection,
|
||||||
|
setActiveSection,
|
||||||
|
sectionState,
|
||||||
|
setSectionState,
|
||||||
|
sectionCommits,
|
||||||
|
setSectionCommits,
|
||||||
|
lastSectionSnapshot,
|
||||||
|
setLastSectionSnapshot,
|
||||||
|
persistedEntities,
|
||||||
|
setPersistedEntities,
|
||||||
|
pendingEntityCreates,
|
||||||
|
setPendingEntityCreates,
|
||||||
|
createdEntities,
|
||||||
|
setCreatedEntities,
|
||||||
|
entityStatus,
|
||||||
|
setEntityStatus,
|
||||||
|
selectedFeatureId,
|
||||||
|
setSelectedFeatureId,
|
||||||
|
entityForm,
|
||||||
|
setEntityForm,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
geometryMetaForm,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
isEntitySubmitting,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
entityFormStatus,
|
||||||
|
setEntityFormStatus,
|
||||||
|
entitySearchQuery,
|
||||||
|
setEntitySearchQuery,
|
||||||
|
entitySearchResults,
|
||||||
|
setEntitySearchResults,
|
||||||
|
selectedSearchEntityId,
|
||||||
|
setSelectedSearchEntityId,
|
||||||
|
isEntitySearchLoading,
|
||||||
|
setIsEntitySearchLoading,
|
||||||
|
timelineYear,
|
||||||
|
setTimelineYear,
|
||||||
|
timelineDraftYear,
|
||||||
|
setTimelineDraftYear,
|
||||||
|
isTimelineLoading,
|
||||||
|
setIsTimelineLoading,
|
||||||
|
timelineStatus,
|
||||||
|
setTimelineStatus,
|
||||||
|
backgroundVisibility,
|
||||||
|
setBackgroundVisibility,
|
||||||
|
isBackgroundVisibilityReady,
|
||||||
|
setIsBackgroundVisibilityReady,
|
||||||
|
} = useEditorSessionState({
|
||||||
|
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||||
|
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
||||||
|
fallbackTimelineRange: FIXED_TIMELINE_RANGE,
|
||||||
|
currentYear: CURRENT_YEAR,
|
||||||
|
});
|
||||||
|
// Counter để bỏ qua response cũ khi user đổi timeline/section liên tục.
|
||||||
|
const timelineFetchRequestRef = useRef(0);
|
||||||
|
// Counter để bỏ qua response cũ khi user gõ search entity liên tục.
|
||||||
|
const entitySearchRequestRef = useRef(0);
|
||||||
|
|
||||||
|
const editor = useEditorState(initialData);
|
||||||
|
const editorUserId = normalizeEditorUserId(editorUserIdInput);
|
||||||
|
const entities = useMemo(
|
||||||
|
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
|
||||||
|
[persistedEntities, pendingEntityCreates]
|
||||||
|
);
|
||||||
|
const selectedFeature =
|
||||||
|
selectedFeatureId === null
|
||||||
|
? null
|
||||||
|
: editor.draft.features.find((feature) =>
|
||||||
|
String(feature.properties.id) === String(selectedFeatureId)
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
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 sectionCommands = useSectionCommands({
|
||||||
|
editor,
|
||||||
|
editorUserId,
|
||||||
|
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||||
|
activeSection,
|
||||||
|
sectionState,
|
||||||
|
selectedSectionId,
|
||||||
|
newSectionTitle,
|
||||||
|
pendingSaveCount: editor.changeCount + pendingEntityCreates.length,
|
||||||
|
pendingEntityCreates,
|
||||||
|
lastSectionSnapshot,
|
||||||
|
commitTitle,
|
||||||
|
commitNote,
|
||||||
|
setActiveSection,
|
||||||
|
setSelectedSectionId,
|
||||||
|
setSectionState,
|
||||||
|
setLastSectionSnapshot,
|
||||||
|
setInitialData,
|
||||||
|
setSectionCommits,
|
||||||
|
setPendingEntityCreates,
|
||||||
|
setCreatedEntities,
|
||||||
|
setEntityFormStatus,
|
||||||
|
setSelectedFeatureId,
|
||||||
|
setEntityStatus,
|
||||||
|
setIsSaving,
|
||||||
|
setIsSubmitting,
|
||||||
|
setIsOpeningSection,
|
||||||
|
setAvailableSections,
|
||||||
|
setNewSectionTitle,
|
||||||
|
setCommitTitle,
|
||||||
|
setCommitNote,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
openSectionForEditing,
|
||||||
|
commitSection,
|
||||||
|
submitCurrentSection,
|
||||||
|
restoreCommit,
|
||||||
|
} = sectionCommands;
|
||||||
|
|
||||||
|
const openProject = useCallback(async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
try {
|
||||||
|
setIsOpeningSection(true);
|
||||||
|
setEntityStatus(null);
|
||||||
|
await openSectionForEditing(projectId);
|
||||||
|
setEntityStatus(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
if (err.status === 401 || err.status === 400) {
|
||||||
|
router.replace("/signin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEntityStatus(`Mở project thất bại: ${err.body || err.message}`);
|
||||||
|
} else {
|
||||||
|
console.error("Open project failed", err);
|
||||||
|
setEntityStatus("Mở project thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsOpeningSection(false);
|
||||||
|
}
|
||||||
|
}, [openSectionForEditing, projectId, router, setEntityStatus, setIsOpeningSection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
async function ensureAuthenticated() {
|
||||||
|
try {
|
||||||
|
await fetchCurrentUser();
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed) return;
|
||||||
|
// Follow the same behavior as the rest of FrontEndAdmin: unauthenticated -> /signin.
|
||||||
|
router.replace("/signin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAuthenticated();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
if (openedProjectIdRef.current === projectId) return;
|
||||||
|
|
||||||
|
openProject()
|
||||||
|
.then(() => {
|
||||||
|
openedProjectIdRef.current = projectId;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// allow retry if openProject threw outside its try/catch (should be rare)
|
||||||
|
openedProjectIdRef.current = null;
|
||||||
|
});
|
||||||
|
}, [openProject]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
async function loadEntities() {
|
||||||
|
try {
|
||||||
|
const rows = await fetchEntities();
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
setPersistedEntities(rows);
|
||||||
|
setEntityStatus(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed) return;
|
||||||
|
console.error("Load entities failed", err);
|
||||||
|
setEntityStatus("Không tải được danh sách entity.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEntities();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [setEntityStatus, setPersistedEntities]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
setIsEntitySearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = entitySearchQuery.trim();
|
||||||
|
if (!keyword.length) {
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
setIsEntitySearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
const requestId = ++entitySearchRequestRef.current;
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
setIsEntitySearchLoading(true);
|
||||||
|
try {
|
||||||
|
const rows = await searchEntitiesByName(keyword, { limit: 30 });
|
||||||
|
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
||||||
|
|
||||||
|
const pendingMatches = pendingEntityCreates
|
||||||
|
.filter((entity) =>
|
||||||
|
entity.name.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||||
|
(entity.slug || "").toLowerCase().includes(keyword.toLowerCase())
|
||||||
|
)
|
||||||
|
.map<Entity>((entity) => ({
|
||||||
|
id: entity.id,
|
||||||
|
name: entity.name,
|
||||||
|
slug: entity.slug,
|
||||||
|
type_id: entity.type_id,
|
||||||
|
status: entity.status,
|
||||||
|
geometry_count: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mergedRows = mergeEntitySearchResults(rows, pendingMatches);
|
||||||
|
setEntitySearchResults(mergedRows);
|
||||||
|
setSelectedSearchEntityId((prev) =>
|
||||||
|
prev && mergedRows.some((entity) => entity.id === prev)
|
||||||
|
? prev
|
||||||
|
: mergedRows[0]?.id || null
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
||||||
|
console.error("Search entity by name failed", err);
|
||||||
|
const pendingMatches = pendingEntityCreates
|
||||||
|
.filter((entity) =>
|
||||||
|
entity.name.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||||
|
(entity.slug || "").toLowerCase().includes(keyword.toLowerCase())
|
||||||
|
)
|
||||||
|
.map<Entity>((entity) => ({
|
||||||
|
id: entity.id,
|
||||||
|
name: entity.name,
|
||||||
|
slug: entity.slug,
|
||||||
|
type_id: entity.type_id,
|
||||||
|
status: entity.status,
|
||||||
|
geometry_count: 0,
|
||||||
|
}));
|
||||||
|
setEntitySearchResults(pendingMatches);
|
||||||
|
setSelectedSearchEntityId(pendingMatches[0]?.id || null);
|
||||||
|
} finally {
|
||||||
|
if (!disposed && requestId === entitySearchRequestRef.current) {
|
||||||
|
setIsEntitySearchLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
entitySearchQuery,
|
||||||
|
selectedFeature,
|
||||||
|
pendingEntityCreates,
|
||||||
|
setEntitySearchResults,
|
||||||
|
setIsEntitySearchLoading,
|
||||||
|
setSelectedSearchEntityId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFeatureId === null) return;
|
||||||
|
const stillExists = editor.draft.features.some((feature) =>
|
||||||
|
String(feature.properties.id) === String(selectedFeatureId)
|
||||||
|
);
|
||||||
|
if (!stillExists) {
|
||||||
|
setSelectedFeatureId(null);
|
||||||
|
}
|
||||||
|
}, [editor.draft, selectedFeatureId, setSelectedFeatureId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setSelectedGeometryEntityIds([]);
|
||||||
|
setGeometryMetaForm({
|
||||||
|
time_start: "",
|
||||||
|
time_end: "",
|
||||||
|
binding: "",
|
||||||
|
});
|
||||||
|
setEntitySearchQuery("");
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
|
||||||
|
setSelectedGeometryEntityIds(featureEntityIds);
|
||||||
|
setGeometryMetaForm({
|
||||||
|
time_start: selectedFeature.properties.time_start != null
|
||||||
|
? String(selectedFeature.properties.time_start)
|
||||||
|
: "",
|
||||||
|
time_end: selectedFeature.properties.time_end != null
|
||||||
|
? String(selectedFeature.properties.time_end)
|
||||||
|
: "",
|
||||||
|
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
|
||||||
|
});
|
||||||
|
setEntitySearchQuery("");
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
}, [
|
||||||
|
selectedFeature,
|
||||||
|
setEntityFormStatus,
|
||||||
|
setEntitySearchQuery,
|
||||||
|
setEntitySearchResults,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
setSelectedSearchEntityId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedFeature) return;
|
||||||
|
|
||||||
|
const allowedGroupIds = getAllowedEntityTypeGroupIdsForFeature(selectedFeature);
|
||||||
|
const fallbackOption = ENTITY_TYPE_OPTIONS.find((option) =>
|
||||||
|
allowedGroupIds.includes(option.groupId)
|
||||||
|
);
|
||||||
|
if (!fallbackOption) return;
|
||||||
|
|
||||||
|
setEntityForm((prev) => {
|
||||||
|
const currentOption = findEntityTypeOption(prev.type_id);
|
||||||
|
const isCurrentAllowed = currentOption
|
||||||
|
? allowedGroupIds.includes(currentOption.groupId)
|
||||||
|
: false;
|
||||||
|
if (isCurrentAllowed || prev.type_id === fallbackOption.value) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
type_id: fallbackOption.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [selectedFeature, setEntityForm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
if (timelineDraftYear !== timelineYear) {
|
||||||
|
setTimelineYear(timelineDraftYear);
|
||||||
|
}
|
||||||
|
}, TIMELINE_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [timelineDraftYear, timelineYear, setTimelineYear]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||||
|
setIsBackgroundVisibilityReady(true);
|
||||||
|
}, [setBackgroundVisibility, setIsBackgroundVisibilityReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSection) return;
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
const requestId = ++timelineFetchRequestRef.current;
|
||||||
|
|
||||||
|
async function loadGlobalByTimeline() {
|
||||||
|
setIsTimelineLoading(true);
|
||||||
|
setTimelineStatus(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchGeometriesByBBox({
|
||||||
|
...WORLD_BBOX,
|
||||||
|
time: timelineYear,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||||
|
setInitialData(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
console.error("Load global timeline data failed", err.body);
|
||||||
|
} else {
|
||||||
|
console.error("Load global timeline data failed", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||||
|
setTimelineStatus("Không tải được geometry global tại mốc thời gian đã chọn.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||||
|
setIsTimelineLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGlobalByTimeline();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
timelineYear,
|
||||||
|
activeSection,
|
||||||
|
setInitialData,
|
||||||
|
setIsTimelineLoading,
|
||||||
|
setTimelineStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updateBackgroundVisibility = (
|
||||||
|
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
|
||||||
|
) => {
|
||||||
|
setBackgroundVisibility((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
persistBackgroundLayerVisibility(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
||||||
|
updateBackgroundVisibility((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: !prev[id],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAllBackgroundLayers = () => {
|
||||||
|
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHideAllBackgroundLayers = () => {
|
||||||
|
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimelineYearChange = (nextYear: number) => {
|
||||||
|
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
|
||||||
|
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => {
|
||||||
|
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEntityIdsChange = (values: string[]) => {
|
||||||
|
setSelectedGeometryEntityIds(uniqueEntityIds(values));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSelectedSearchEntity = () => {
|
||||||
|
const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : "";
|
||||||
|
if (!entityId.length) {
|
||||||
|
setEntityFormStatus("Hãy chọn một entity từ kết quả search trước.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]);
|
||||||
|
setSelectedGeometryEntityIds(next);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const featureCommands = useFeatureCommands({
|
||||||
|
editor,
|
||||||
|
selectedFeature,
|
||||||
|
geometryMetaForm,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
entities,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
setEntityFormStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateEntityOnly = async () => {
|
||||||
|
const name = entityForm.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
setEntityFormStatus("Tên entity là bắt buộc.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = entityForm.slug.trim() || null;
|
||||||
|
const typeId = entityForm.type_id || DEFAULT_ENTITY_TYPE_ID;
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
const duplicatedName = entities.some((entity) => entity.name.trim().toLowerCase() === normalizedName);
|
||||||
|
if (duplicatedName) {
|
||||||
|
setEntityFormStatus("Tên entity đã tồn tại.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (slug) {
|
||||||
|
const normalizedSlug = slug.toLowerCase();
|
||||||
|
const duplicatedSlug = entities.some((entity) =>
|
||||||
|
(entity.slug || "").trim().toLowerCase() === normalizedSlug
|
||||||
|
);
|
||||||
|
if (duplicatedSlug) {
|
||||||
|
setEntityFormStatus("Slug entity đã tồn tại.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityId = buildClientEntityId();
|
||||||
|
const pendingCreate: PendingEntityCreate = {
|
||||||
|
id: entityId,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
type_id: typeId,
|
||||||
|
status: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
try {
|
||||||
|
setPendingEntityCreates((prev) => [pendingCreate, ...prev]);
|
||||||
|
setCreatedEntities((prev) => {
|
||||||
|
if (prev.some((item) => item.id === pendingCreate.id)) return prev;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: pendingCreate.id,
|
||||||
|
name: pendingCreate.name,
|
||||||
|
type_id: pendingCreate.type_id || null,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
setEntityForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
}));
|
||||||
|
setEntityStatus(null);
|
||||||
|
setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Commit.");
|
||||||
|
|
||||||
|
if (selectedFeature) {
|
||||||
|
setEntitySearchQuery(pendingCreate.name);
|
||||||
|
setSelectedSearchEntityId(pendingCreate.id);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsEntitySubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
|
||||||
|
const headCommit = sectionState?.head_commit_id
|
||||||
|
? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null
|
||||||
|
: null;
|
||||||
|
const timelineDisabled = isSaving || pendingSaveCount > 0;
|
||||||
|
const timelineStatusText =
|
||||||
|
pendingSaveCount > 0
|
||||||
|
? "Commit hoặc Undo hết thay đổi trước khi đổi mốc thời gian."
|
||||||
|
: isSaving
|
||||||
|
? "Đang lưu thay đổi..."
|
||||||
|
: timelineStatus;
|
||||||
|
|
||||||
|
const handleCreateFeature = (feature: Feature) => {
|
||||||
|
editor.createFeature(feature);
|
||||||
|
setSelectedFeatureId(feature.properties.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||||
|
<Editor
|
||||||
|
mode={mode}
|
||||||
|
setMode={setMode}
|
||||||
|
entityStatus={entityStatus}
|
||||||
|
onUndo={editor.undo}
|
||||||
|
onCommit={commitSection}
|
||||||
|
onSubmit={submitCurrentSection}
|
||||||
|
onRestoreCommit={restoreCommit}
|
||||||
|
isSaving={isSaving}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
sectionTitle={activeSection?.title || "Đang tải project"}
|
||||||
|
sectionStatus={sectionState?.status || "editing"}
|
||||||
|
commitTitle={commitTitle}
|
||||||
|
commitNote={commitNote}
|
||||||
|
onCommitTitleChange={setCommitTitle}
|
||||||
|
onCommitNoteChange={setCommitNote}
|
||||||
|
commitCount={sectionCommits.length}
|
||||||
|
hasHeadCommit={Boolean(sectionState?.head_commit_id)}
|
||||||
|
headCommitId={sectionState?.head_commit_id || null}
|
||||||
|
latestCommitLabel={headCommit ? `Head: ${formatCommitTitle(headCommit)}` : null}
|
||||||
|
commits={sectionCommits}
|
||||||
|
changesCount={pendingSaveCount}
|
||||||
|
undoStack={editor.undoStack}
|
||||||
|
createdEntities={createdEntities}
|
||||||
|
createdGeometries={createdGeometries}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||||
|
{isBackgroundVisibilityReady ? (
|
||||||
|
<Map
|
||||||
|
mode={mode}
|
||||||
|
draft={editor.draft}
|
||||||
|
selectedFeatureId={selectedFeatureId}
|
||||||
|
onSelectFeatureId={setSelectedFeatureId}
|
||||||
|
onCreateFeature={handleCreateFeature}
|
||||||
|
onDeleteFeature={editor.deleteFeature}
|
||||||
|
onUpdateFeature={editor.updateFeature}
|
||||||
|
backgroundVisibility={backgroundVisibility}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||||
|
)}
|
||||||
|
<TimelineBar
|
||||||
|
year={timelineDraftYear}
|
||||||
|
onYearChange={handleTimelineYearChange}
|
||||||
|
isLoading={isTimelineLoading}
|
||||||
|
disabled={timelineDisabled}
|
||||||
|
statusText={timelineStatusText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BackgroundLayersPanel
|
||||||
|
visibility={backgroundVisibility}
|
||||||
|
onToggleLayer={handleToggleBackgroundLayer}
|
||||||
|
onShowAll={handleShowAllBackgroundLayers}
|
||||||
|
onHideAll={handleHideAllBackgroundLayers}
|
||||||
|
topContent={
|
||||||
|
<SelectedGeometryPanel
|
||||||
|
selectedFeature={selectedFeature}
|
||||||
|
selectedFeatureEntitySummary={
|
||||||
|
selectedFeature
|
||||||
|
? formatEntityNamesForDisplay(selectedFeature, entities)
|
||||||
|
: "Chưa gắn"
|
||||||
|
}
|
||||||
|
selectedFeatureBindingSummary={
|
||||||
|
selectedFeature
|
||||||
|
? formatBindingIdsForDisplay(selectedFeature)
|
||||||
|
: "Không có"
|
||||||
|
}
|
||||||
|
entities={entities}
|
||||||
|
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
||||||
|
onEntityIdsChange={handleEntityIdsChange}
|
||||||
|
entitySearchQuery={entitySearchQuery}
|
||||||
|
onEntitySearchQueryChange={setEntitySearchQuery}
|
||||||
|
entitySearchResults={entitySearchResults}
|
||||||
|
selectedSearchEntityId={selectedSearchEntityId}
|
||||||
|
onSelectSearchEntityId={setSelectedSearchEntityId}
|
||||||
|
onAddSelectedSearchEntity={handleAddSelectedSearchEntity}
|
||||||
|
isEntitySearchLoading={isEntitySearchLoading}
|
||||||
|
entityForm={entityForm}
|
||||||
|
onEntityFormChange={handleEntityFormChange}
|
||||||
|
entityTypeOptions={ENTITY_TYPE_OPTIONS}
|
||||||
|
geometryMetaForm={geometryMetaForm}
|
||||||
|
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
||||||
|
isEntitySubmitting={isEntitySubmitting}
|
||||||
|
onCreateEntityOnly={handleCreateEntityOnly}
|
||||||
|
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
|
||||||
|
onApplyEntitiesForSelectedGeometry={featureCommands.applyEntitiesToSelectedGeometry}
|
||||||
|
changeCount={editor.changeCount}
|
||||||
|
entityFormStatus={entityFormStatus}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditorUserId(value: string): string {
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized || DEFAULT_EDITOR_USER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommitTitle(commit: SectionCommit): string {
|
||||||
|
return commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedEntityTypeGroupIdsForFeature(feature: Feature): EntityTypeGroupId[] {
|
||||||
|
const defaultTypeId = getDefaultTypeIdForFeature(feature);
|
||||||
|
const defaultTypeOption = findEntityTypeOption(defaultTypeId);
|
||||||
|
if (defaultTypeOption) {
|
||||||
|
return [defaultTypeOption.groupId];
|
||||||
|
}
|
||||||
|
return ["polygon"];
|
||||||
|
}
|
||||||
7
src/app/editor/page.tsx
Normal file
7
src/app/editor/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function EditorIndexPage() {
|
||||||
|
// Editor must be opened from a specific project (see /user/projects).
|
||||||
|
redirect("/user/projects");
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ import Swal from "sweetalert2";
|
|||||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||||
import { apiAddProjectMember, apiChangeProjectOwner, apiDeleteProject, apiGetProjectDetail, apiRemoveProjectMember, apiUpdateProject, apiUpdateProjectMemberRole } from "@/service/projectService";
|
import { apiAddProjectMember, apiChangeProjectOwner, apiDeleteProject, apiGetProjectDetail, apiRemoveProjectMember, apiUpdateProject, apiUpdateProjectMemberRole } from "@/service/projectService";
|
||||||
import Loading from "@/app/loading";
|
import Loading from "@/app/loading";
|
||||||
|
import Button from "@/components/ui/button/Button";
|
||||||
|
|
||||||
type TabType = "overview" | "members" | "settings";
|
type TabType = "overview" | "members" | "settings";
|
||||||
|
|
||||||
@@ -256,7 +257,7 @@ export default function ProjectDetailsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
id: "overview",
|
id: "overview",
|
||||||
@@ -305,6 +306,11 @@ export default function ProjectDetailsPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}`)}>
|
||||||
|
Mo editor
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,7 +217,15 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center mt-4 md:mt-0 w-[120px] justify-end shrink-0">
|
<div className="flex items-center mt-4 md:mt-0 gap-10 w-[240px] justify-end shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/editor/${project.id}`)}
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="flex -space-x-2 overflow-hidden">
|
<div className="flex -space-x-2 overflow-hidden">
|
||||||
{project.members && project.members.length > 0 ? (
|
{project.members && project.members.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -330,4 +338,4 @@ export default function ProjectsPage() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
331
src/app/user/submissions/[id]/page.tsx
Normal file
331
src/app/user/submissions/[id]/page.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||||
|
import ComponentCard from "@/components/common/ComponentCard";
|
||||||
|
import Badge from "@/components/ui/badge/Badge";
|
||||||
|
import Button from "@/components/ui/button/Button";
|
||||||
|
|
||||||
|
import Map from "@/uhm/components/Map";
|
||||||
|
import { DEFAULT_BACKGROUND_LAYER_VISIBILITY } from "@/uhm/lib/backgroundLayers";
|
||||||
|
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/geo/constants";
|
||||||
|
import { fetchSectionCommits } from "@/uhm/api/sections";
|
||||||
|
import type { EditorSnapshot, SectionCommit } from "@/uhm/types/sections";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
|
||||||
|
import type { Submission } from "@/interface/submission";
|
||||||
|
import { apiGetSubmissionById } from "@/service/submissionService";
|
||||||
|
import type { Project } from "@/interface/project";
|
||||||
|
import { apiGetProjectDetail } from "@/service/projectService";
|
||||||
|
|
||||||
|
function formatTime(value?: string | null) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(value);
|
||||||
|
return d.toLocaleString("vi-VN");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubmissionDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = String(params.id || "");
|
||||||
|
const [row, setRow] = useState<Submission | null>(null);
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [commits, setCommits] = useState<SectionCommit[]>([]);
|
||||||
|
const [snapshot, setSnapshot] = useState<EditorSnapshot | null>(null);
|
||||||
|
const [snapshotEntities, setSnapshotEntities] = useState<EntitySnapshot[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isLoadingExtras, setIsLoadingExtras] = useState(false);
|
||||||
|
|
||||||
|
const headCommitSnapshotJson = useMemo(() => {
|
||||||
|
const headId = project?.latest_commit_id || null;
|
||||||
|
if (!headId) return null;
|
||||||
|
const head = commits.find((c) => c.id === headId) || null;
|
||||||
|
return (head as any)?.snapshot_json ?? null;
|
||||||
|
}, [commits, project?.latest_commit_id]);
|
||||||
|
|
||||||
|
const draft = useMemo(
|
||||||
|
() => snapshot?.editor_feature_collection || EMPTY_FEATURE_COLLECTION,
|
||||||
|
[snapshot]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
async function load() {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const res = await apiGetSubmissionById(id);
|
||||||
|
if (!disposed) setRow(res?.data || null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("Khong the tai submission.");
|
||||||
|
} finally {
|
||||||
|
if (!disposed) setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
async function loadExtras() {
|
||||||
|
if (!row?.project_id) return;
|
||||||
|
try {
|
||||||
|
setIsLoadingExtras(true);
|
||||||
|
|
||||||
|
const [projectRes, commitRows] = await Promise.all([
|
||||||
|
apiGetProjectDetail(row.project_id),
|
||||||
|
fetchSectionCommits(row.project_id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (disposed) return;
|
||||||
|
setProject(projectRes?.data || null);
|
||||||
|
setCommits(commitRows || []);
|
||||||
|
|
||||||
|
const commit = (commitRows || []).find((c) => c.id === row.commit_id) || null;
|
||||||
|
const snap = (commit?.snapshot_json || null) as EditorSnapshot | null;
|
||||||
|
setSnapshot(snap);
|
||||||
|
setSnapshotEntities((snap?.entities || []) as EntitySnapshot[]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("Khong the tai thong tin project/commit.");
|
||||||
|
} finally {
|
||||||
|
if (!disposed) setIsLoadingExtras(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadExtras();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [row?.commit_id, row?.project_id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto pb-10">
|
||||||
|
<PageBreadcrumb
|
||||||
|
pageTitle="Chi tiet submission"
|
||||||
|
paths={[{ name: "Kiem duyet submissions", href: "/user/submissions" }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<ComponentCard title="Thong tin">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">Dang tai...</div>
|
||||||
|
) : row ? (
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">ID</div>
|
||||||
|
<div className="font-mono break-all">{row.id}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Status</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge size="sm" variant="light" color="light">
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Project</div>
|
||||||
|
<div className="font-medium break-words">{row.project_title || "-"}</div>
|
||||||
|
<div className="font-mono break-all text-xs text-gray-500 dark:text-gray-400 mt-1">{row.project_id}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Commit</div>
|
||||||
|
<div className="font-mono break-all">{row.commit_id}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">User</div>
|
||||||
|
<div className="font-mono break-all">{row.user_id}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Created</div>
|
||||||
|
<div>{formatTime(row.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Reviewed by</div>
|
||||||
|
<div className="font-mono break-all">{row.reviewed_by || "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Reviewed at</div>
|
||||||
|
<div>{formatTime(row.reviewed_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Review note</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap">{row.review_note || "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">Content</div>
|
||||||
|
<div className="mt-1 whitespace-pre-wrap">{row.content || "-"}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 flex justify-end">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => (window.location.href = `/editor/${row.project_id}`)}>
|
||||||
|
Open editor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">Khong tim thay submission.</div>
|
||||||
|
)}
|
||||||
|
</ComponentCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{row ? (
|
||||||
|
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<ComponentCard title="Map view">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="rounded-xl overflow-hidden border border-gray-200 dark:border-gray-800">
|
||||||
|
<Map
|
||||||
|
mode="idle"
|
||||||
|
draft={draft}
|
||||||
|
selectedFeatureId={null}
|
||||||
|
onSelectFeatureId={() => {}}
|
||||||
|
backgroundVisibility={DEFAULT_BACKGROUND_LAYER_VISIBILITY}
|
||||||
|
allowGeometryEditing={false}
|
||||||
|
respectBindingFilter={false}
|
||||||
|
height="320px"
|
||||||
|
fitToDraftBounds
|
||||||
|
fitBoundsKey={row.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isLoadingExtras ? (
|
||||||
|
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">Dang tai snapshot/commits...</div>
|
||||||
|
) : snapshot ? (
|
||||||
|
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Snapshot schema_version: {snapshot.schema_version}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Khong tim thay snapshot cho commit nay.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
|
||||||
|
<ComponentCard title="Entities (snapshot)">
|
||||||
|
<div className="p-4">
|
||||||
|
{snapshotEntities.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">Khong co entities trong snapshot.</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-full overflow-x-auto">
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[720px]">
|
||||||
|
<div className="grid grid-cols-12 gap-4 px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22] text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
<div className="col-span-2">Op</div>
|
||||||
|
<div className="col-span-6">Name</div>
|
||||||
|
<div className="col-span-4">Entity ID</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
{snapshotEntities.map((e) => (
|
||||||
|
<div key={`${e.operation}:${e.id}`} className="grid grid-cols-12 gap-4 px-5 py-3 text-sm">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Badge size="sm" variant="light" color="dark">
|
||||||
|
{e.operation}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-6 min-w-0 truncate">{e.name || "-"}</div>
|
||||||
|
<div className="col-span-4 font-mono text-xs break-all">{e.id}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{row ? (
|
||||||
|
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<ComponentCard title="Head commit snapshot_json">
|
||||||
|
<div className="p-4">
|
||||||
|
<pre className="text-xs whitespace-pre-wrap break-words rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117] p-4 overflow-auto max-h-[420px]">
|
||||||
|
{JSON.stringify(headCommitSnapshotJson, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
|
||||||
|
<ComponentCard title="Project members">
|
||||||
|
<div className="p-4">
|
||||||
|
{!project ? (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">Khong co du lieu project.</div>
|
||||||
|
) : (project.members || []).length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">Khong co thanh vien.</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-full overflow-x-auto">
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[640px]">
|
||||||
|
<div className="grid grid-cols-12 gap-4 px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22] text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
<div className="col-span-5">Member</div>
|
||||||
|
<div className="col-span-3">Role</div>
|
||||||
|
<div className="col-span-4">User ID</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
{(project.members || []).map((m) => (
|
||||||
|
<div key={m.user_id} className="grid grid-cols-12 gap-4 px-5 py-3 text-sm">
|
||||||
|
<div className="col-span-5 min-w-0 truncate">{m.display_name || "-"}</div>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Badge size="sm" variant="light" color="info">
|
||||||
|
{m.role}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-4 font-mono text-xs break-all">{m.user_id}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{row ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<ComponentCard title="Commits">
|
||||||
|
<div className="p-4">
|
||||||
|
{commits.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">Khong co commits.</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-full overflow-x-auto">
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[900px]">
|
||||||
|
<div className="grid grid-cols-12 gap-4 px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22] text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
<div className="col-span-3">Commit</div>
|
||||||
|
<div className="col-span-5">Title</div>
|
||||||
|
<div className="col-span-2">Created</div>
|
||||||
|
<div className="col-span-2">User</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
{commits.map((c) => {
|
||||||
|
const isTarget = c.id === row.commit_id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`grid grid-cols-12 gap-4 px-5 py-3 text-sm ${isTarget ? "bg-brand-50/60 dark:bg-brand-500/10" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="col-span-3 font-mono text-xs break-all">
|
||||||
|
{isTarget ? <b>{c.id}</b> : c.id}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-5 min-w-0 truncate">{c.edit_summary || "-"}</div>
|
||||||
|
<div className="col-span-2 text-xs text-gray-600 dark:text-gray-300">{formatTime(c.created_at)}</div>
|
||||||
|
<div className="col-span-2 font-mono text-xs break-all">{c.user_id}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
src/app/user/submissions/page.tsx
Normal file
326
src/app/user/submissions/page.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||||
|
import ComponentCard from "@/components/common/ComponentCard";
|
||||||
|
import Badge from "@/components/ui/badge/Badge";
|
||||||
|
import Button from "@/components/ui/button/Button";
|
||||||
|
import Label from "@/components/form/Label";
|
||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
|
import { useModal } from "@/hooks/useModal";
|
||||||
|
|
||||||
|
import type { Submission, SubmissionStatus } from "@/interface/submission";
|
||||||
|
import { apiSearchSubmissions, apiUpdateSubmissionStatus } from "@/service/submissionService";
|
||||||
|
|
||||||
|
type Decision = "APPROVED" | "REJECTED";
|
||||||
|
|
||||||
|
function statusBadge(status: SubmissionStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case "PENDING":
|
||||||
|
return (
|
||||||
|
<Badge size="sm" variant="light" color="warning">
|
||||||
|
PENDING
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "APPROVED":
|
||||||
|
return (
|
||||||
|
<Badge size="sm" variant="light" color="success">
|
||||||
|
APPROVED
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "REJECTED":
|
||||||
|
return (
|
||||||
|
<Badge size="sm" variant="light" color="error">
|
||||||
|
REJECTED
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge size="sm" variant="light" color="light">
|
||||||
|
{String(status || "UNKNOWN")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: string | null) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(value);
|
||||||
|
return d.toLocaleString("vi-VN");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubmissionsPage() {
|
||||||
|
const [items, setItems] = useState<Submission[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const limit = 20;
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [projectId, setProjectId] = useState("");
|
||||||
|
const [status, setStatus] = useState<"ALL" | "PENDING" | "APPROVED" | "REJECTED">("PENDING");
|
||||||
|
|
||||||
|
const { isOpen, openModal, closeModal } = useModal();
|
||||||
|
const [active, setActive] = useState<Submission | null>(null);
|
||||||
|
const [decision, setDecision] = useState<Decision>("APPROVED");
|
||||||
|
const [reviewNote, setReviewNote] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const query = useMemo(() => {
|
||||||
|
const trimmedSearch = search.trim();
|
||||||
|
const trimmedProject = projectId.trim();
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
project_id: trimmedProject.length ? trimmedProject : undefined,
|
||||||
|
search: trimmedSearch.length ? trimmedSearch : undefined,
|
||||||
|
statuses: status === "ALL" ? undefined : ([status] as any),
|
||||||
|
sort: "created_at" as const,
|
||||||
|
};
|
||||||
|
}, [limit, page, projectId, search, status]);
|
||||||
|
|
||||||
|
const fetchList = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const res = await apiSearchSubmissions(query);
|
||||||
|
const payload = res?.data;
|
||||||
|
const rows = payload?.data || [];
|
||||||
|
setItems(rows);
|
||||||
|
setTotalPages(payload?.pagination?.total_pages || 1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("Khong the tai danh sach submissions.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchList();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const openReview = (row: Submission, nextDecision: Decision) => {
|
||||||
|
setActive(row);
|
||||||
|
setDecision(nextDecision);
|
||||||
|
setReviewNote("");
|
||||||
|
openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitDecision = async () => {
|
||||||
|
if (!active) return;
|
||||||
|
const note = reviewNote.trim();
|
||||||
|
if (note.length < 10) {
|
||||||
|
toast.error("Review note toi thieu 10 ky tu.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
await apiUpdateSubmissionStatus(active.id, { status: decision, review_note: note });
|
||||||
|
toast.success(decision === "APPROVED" ? "Da duyet submission." : "Da tu choi submission.");
|
||||||
|
closeModal();
|
||||||
|
await fetchList();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error(err?.response?.data?.message || "Cap nhat trang thai that bai.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto pb-10">
|
||||||
|
<PageBreadcrumb pageTitle="Kiem duyet submissions" />
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<ComponentCard title="Danh sach submissions">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-5">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label>Search</Label>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setSearch(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Tim theo keyword (>= 2 ky tu)"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Project ID</Label>
|
||||||
|
<input
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setProjectId(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="UUID"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Status</Label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setStatus(e.target.value as any);
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="PENDING">PENDING</option>
|
||||||
|
<option value="APPROVED">APPROVED</option>
|
||||||
|
<option value="REJECTED">REJECTED</option>
|
||||||
|
<option value="ALL">ALL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative min-h-[260px]">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/50 dark:bg-gray-900/50 rounded-xl">
|
||||||
|
<div className="w-10 h-10 border-4 border-t-brand-500 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="max-w-full overflow-x-auto">
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[900px]">
|
||||||
|
<div className="grid grid-cols-12 gap-4 px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22] text-xs font-semibold text-gray-600 dark:text-gray-300">
|
||||||
|
<div className="col-span-4">Project</div>
|
||||||
|
<div className="col-span-2">Submitter</div>
|
||||||
|
<div className="col-span-1">Status</div>
|
||||||
|
<div className="col-span-2">Created</div>
|
||||||
|
<div className="col-span-3 text-right">Actions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">Khong co submissions.</div>
|
||||||
|
) : null}
|
||||||
|
{items.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className="grid grid-cols-12 gap-4 px-5 py-4 text-sm hover:bg-gray-50 dark:hover:bg-[#161b22] cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target && target.closest("button")) return;
|
||||||
|
window.location.href = `/user/submissions/${row.id}`;
|
||||||
|
}}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") window.location.href = `/user/submissions/${row.id}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="col-span-4 min-w-0">
|
||||||
|
<div className="font-medium text-gray-800 dark:text-gray-200 truncate">
|
||||||
|
{row.project_title || row.project_id}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
Submission:{" "}
|
||||||
|
<Link className="hover:underline" href={`/user/submissions/${row.id}`}>
|
||||||
|
{row.id}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
Commit: {row.commit_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 min-w-0 text-xs text-gray-600 dark:text-gray-300 truncate">
|
||||||
|
{row.user?.display_name || row.user?.email || row.user_id}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">{statusBadge(row.status)}</div>
|
||||||
|
<div className="col-span-2 text-xs text-gray-600 dark:text-gray-300">{formatTime(row.created_at)}</div>
|
||||||
|
<div className="col-span-3 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => (window.location.href = `/editor/${row.project_id}`)}
|
||||||
|
>
|
||||||
|
Open editor
|
||||||
|
</Button>
|
||||||
|
{row.status === "PENDING" ? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" className="bg-brand-500 hover:bg-brand-600 text-white" onClick={() => openReview(row, "APPROVED")}>
|
||||||
|
Duyet
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openReview(row, "REJECTED")}>
|
||||||
|
Tu choi
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openReview(row, row.status === "APPROVED" ? "REJECTED" : "APPROVED")}>
|
||||||
|
Doi trang thai
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Page {page} / {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[620px] m-4">
|
||||||
|
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
||||||
|
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">
|
||||||
|
{decision === "APPROVED" ? "Duyet submission" : "Tu choi submission"}
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-4 break-all">
|
||||||
|
{active?.id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Review note (>= 10 ky tu)</Label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={reviewNote}
|
||||||
|
onChange={(e) => setReviewNote(e.target.value)}
|
||||||
|
className="w-full rounded-xl border border-gray-200 bg-transparent px-4 py-3 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 custom-scrollbar"
|
||||||
|
placeholder={decision === "APPROVED" ? "Ly do duyet..." : "Ly do tu choi..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 mt-2">
|
||||||
|
<Button size="sm" variant="outline" type="button" onClick={closeModal} disabled={isSubmitting}>
|
||||||
|
Huy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={submitDecision}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={decision === "APPROVED" ? "bg-brand-500 hover:bg-brand-600 text-white" : "bg-red-600 hover:bg-red-700 text-white"}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Dang xu ly..." : decision === "APPROVED" ? "Duyet" : "Tu choi"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export interface Project {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||||
|
latest_commit_id?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
is_deleted?: boolean;
|
is_deleted?: boolean;
|
||||||
@@ -79,4 +80,4 @@ export interface AddMemberPayload {
|
|||||||
|
|
||||||
export interface UpdateMemberRolePayload {
|
export interface UpdateMemberRolePayload {
|
||||||
role: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
role: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/interface/submission.ts
Normal file
50
src/interface/submission.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export type SubmissionStatus = "PENDING" | "APPROVED" | "REJECTED" | string;
|
||||||
|
|
||||||
|
export type Submission = {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
commit_id: string;
|
||||||
|
user_id: string;
|
||||||
|
created_at?: string | null;
|
||||||
|
status: SubmissionStatus;
|
||||||
|
reviewed_by?: string | null;
|
||||||
|
reviewed_at?: string | null;
|
||||||
|
review_note?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
project_title?: string | null;
|
||||||
|
project_description?: string | null;
|
||||||
|
user?: any;
|
||||||
|
reviewer?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// BackEndGo's /submissions search returns:
|
||||||
|
// CommonResponse { data: PaginatedResponse { data: Submission[], pagination: ... } }
|
||||||
|
export type NestedPaginatedResponse<T> = {
|
||||||
|
status: boolean;
|
||||||
|
message?: string;
|
||||||
|
data: T[];
|
||||||
|
pagination?: {
|
||||||
|
current_page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_records: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
errors?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchSubmissionsParams = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
project_id?: string;
|
||||||
|
sort?: "id" | "created_at" | "reviewed_at" | "status";
|
||||||
|
search?: string;
|
||||||
|
statuses?: Array<"PENDING" | "APPROVED" | "REJECTED">;
|
||||||
|
reviewed_by?: string;
|
||||||
|
created_from?: string;
|
||||||
|
created_to?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateSubmissionStatusPayload = {
|
||||||
|
status: "APPROVED" | "REJECTED";
|
||||||
|
review_note: string;
|
||||||
|
};
|
||||||
37
src/service/submissionService.ts
Normal file
37
src/service/submissionService.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import api from "@/config/config";
|
||||||
|
import { API } from "../../api";
|
||||||
|
import type { CommonResponse } from "@/interface/common";
|
||||||
|
import type {
|
||||||
|
NestedPaginatedResponse,
|
||||||
|
SearchSubmissionsParams,
|
||||||
|
Submission,
|
||||||
|
UpdateSubmissionStatusPayload,
|
||||||
|
} from "@/interface/submission";
|
||||||
|
|
||||||
|
export async function apiSearchSubmissions(
|
||||||
|
params: SearchSubmissionsParams
|
||||||
|
): Promise<CommonResponse<NestedPaginatedResponse<Submission>>> {
|
||||||
|
const response = await api.get(API.Submission.SEARCH, { params });
|
||||||
|
return response?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGetSubmissionById(
|
||||||
|
id: string
|
||||||
|
): Promise<CommonResponse<Submission>> {
|
||||||
|
const response = await api.get(API.Submission.GET_BY_ID(id));
|
||||||
|
return response?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiUpdateSubmissionStatus(
|
||||||
|
id: string,
|
||||||
|
payload: UpdateSubmissionStatusPayload
|
||||||
|
): Promise<CommonResponse<Submission>> {
|
||||||
|
const response = await api.patch(API.Submission.UPDATE_STATUS(id), payload);
|
||||||
|
return response?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDeleteSubmission(id: string): Promise<CommonResponse> {
|
||||||
|
const response = await api.delete(API.Submission.DELETE(id));
|
||||||
|
return response?.data;
|
||||||
|
}
|
||||||
|
|
||||||
33
src/uhm/api/auth.ts
Normal file
33
src/uhm/api/auth.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
|
||||||
|
|
||||||
|
export type AuthTokens = {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CurrentUser = {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
display_name?: string;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
roles?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function signIn(email: string, password: string): Promise<AuthTokens> {
|
||||||
|
const res = await requestJson<AuthTokens>(
|
||||||
|
API_ENDPOINTS.authSignin,
|
||||||
|
jsonRequestInit("POST", { email, password }),
|
||||||
|
// Sign-in sets httpOnly cookies in BackEndGo.
|
||||||
|
{ skipAuth: true }
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await requestJson(API_ENDPOINTS.authLogout, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
||||||
|
return requestJson<CurrentUser>(API_ENDPOINTS.currentUser);
|
||||||
|
}
|
||||||
23
src/uhm/api/config.ts
Normal file
23
src/uhm/api/config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Production BackEndGo API base URL.
|
||||||
|
// For local development, override with NEXT_PUBLIC_API_BASE_URL (e.g. http://localhost:3344).
|
||||||
|
const FALLBACK_API_BASE_URL = "https://history-api.kain.id.vn";
|
||||||
|
|
||||||
|
export const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL || FALLBACK_API_BASE_URL;
|
||||||
|
|
||||||
|
export const API_ENDPOINTS = {
|
||||||
|
geometries: `${API_BASE_URL}/geometries`,
|
||||||
|
entities: `${API_BASE_URL}/entities`,
|
||||||
|
// New API uses projects + commits + submissions (JWT-protected).
|
||||||
|
authSignin: `${API_BASE_URL}/auth/signin`,
|
||||||
|
authRefresh: `${API_BASE_URL}/auth/refresh`,
|
||||||
|
authLogout: `${API_BASE_URL}/auth/logout`,
|
||||||
|
currentUser: `${API_BASE_URL}/users/current`,
|
||||||
|
currentUserProjects: `${API_BASE_URL}/users/current/project`,
|
||||||
|
projects: `${API_BASE_URL}/projects`,
|
||||||
|
submissions: `${API_BASE_URL}/submissions`,
|
||||||
|
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
|
||||||
|
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
|
||||||
|
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata`,
|
||||||
|
rasterTilesMetadata: `${API_BASE_URL}/raster-tiles/metadata`,
|
||||||
|
} as const;
|
||||||
32
src/uhm/api/entities.ts
Normal file
32
src/uhm/api/entities.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { requestJson } from "@/uhm/api/http";
|
||||||
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
|
|
||||||
|
export type { Entity } from "@/uhm/types/entities";
|
||||||
|
|
||||||
|
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
// API mới dùng `name` thay vì `q`.
|
||||||
|
if (query?.q) {
|
||||||
|
params.set("name", query.q);
|
||||||
|
}
|
||||||
|
const suffix = params.toString();
|
||||||
|
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
|
||||||
|
return requestJson<Entity[]>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEntitiesByName(
|
||||||
|
name: string,
|
||||||
|
options?: { limit?: number }
|
||||||
|
): Promise<Entity[]> {
|
||||||
|
const keyword = name.trim();
|
||||||
|
if (!keyword.length) return [];
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ name: keyword });
|
||||||
|
if (options?.limit && Number.isFinite(options.limit)) {
|
||||||
|
params.set("limit", String(Math.trunc(options.limit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// API mới không có `/entities/search`, search qua query string.
|
||||||
|
return requestJson<Entity[]>(`${API_ENDPOINTS.entities}?${params.toString()}`);
|
||||||
|
}
|
||||||
92
src/uhm/api/geometries.ts
Normal file
92
src/uhm/api/geometries.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { requestJson } from "@/uhm/api/http";
|
||||||
|
import type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||||
|
import type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||||
|
|
||||||
|
export type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||||
|
|
||||||
|
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
// API mới dùng snake_case
|
||||||
|
min_lng: String(params.minLng),
|
||||||
|
min_lat: String(params.minLat),
|
||||||
|
max_lng: String(params.maxLng),
|
||||||
|
max_lat: String(params.maxLat),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (params.time !== undefined) {
|
||||||
|
query.set("time", String(params.time));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.entity_id) {
|
||||||
|
query.set("entity_id", params.entity_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGeometriesByBBox(params: GeometriesBBoxQuery): Promise<FeatureCollection> {
|
||||||
|
const url = `${API_ENDPOINTS.geometries}?${buildBBoxQueryString(params)}`;
|
||||||
|
// API mới trả về list geometries, FE cần chuyển thành GeoJSON FeatureCollection.
|
||||||
|
const rows = await requestJson<GeometryRow[]>(url);
|
||||||
|
return geometriesToFeatureCollection(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeometryRow = {
|
||||||
|
id: string;
|
||||||
|
geo_type: string;
|
||||||
|
draw_geometry: unknown;
|
||||||
|
binding?: unknown;
|
||||||
|
time_start?: number;
|
||||||
|
time_end?: number;
|
||||||
|
bbox?: {
|
||||||
|
min_lng: number;
|
||||||
|
min_lat: number;
|
||||||
|
max_lng: number;
|
||||||
|
max_lat: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
|
||||||
|
const features: Feature[] = [];
|
||||||
|
|
||||||
|
for (const row of rows || []) {
|
||||||
|
const geometry = normalizeGeometry(row.draw_geometry);
|
||||||
|
if (!geometry) continue;
|
||||||
|
|
||||||
|
const binding = normalizeBinding(row.binding);
|
||||||
|
|
||||||
|
const properties: FeatureProperties = {
|
||||||
|
id: row.id,
|
||||||
|
type: row.geo_type || null,
|
||||||
|
time_start: row.time_start ?? null,
|
||||||
|
time_end: row.time_end ?? null,
|
||||||
|
binding: binding.length ? binding : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
features.push({
|
||||||
|
type: "Feature",
|
||||||
|
properties,
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "FeatureCollection", features };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeometry(value: unknown): Geometry | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const g = value as any;
|
||||||
|
if (typeof g.type !== "string") return null;
|
||||||
|
if (!("coordinates" in g)) return null;
|
||||||
|
return g as Geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBinding(value: unknown): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => String(v)).filter((v) => v.length > 0);
|
||||||
|
}
|
||||||
|
// Some deployments may return binding as an object; ignore it for FE properties.binding.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
154
src/uhm/api/http.ts
Normal file
154
src/uhm/api/http.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import type { ApiEnvelope } from "@/uhm/types/api";
|
||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
body: string;
|
||||||
|
errors: unknown[];
|
||||||
|
|
||||||
|
constructor(message: string, status: number, body: string, errors: unknown[] = []) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
this.errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackEndGo auth flow: cookie-based (httpOnly access_token/refresh_token).
|
||||||
|
// We intentionally do not store bearer tokens in localStorage in this FE.
|
||||||
|
|
||||||
|
type RequestJsonOptions = {
|
||||||
|
skipAuth?: boolean;
|
||||||
|
skipRefresh?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function requestJson<T>(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
options?: RequestJsonOptions
|
||||||
|
): Promise<T> {
|
||||||
|
return requestJsonInternal<T>(input, init, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonRequestInit(method: string, body: unknown): RequestInit {
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJsonInternal<T>(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
options?: RequestJsonOptions
|
||||||
|
): Promise<T> {
|
||||||
|
const nextInit = withAuthHeaders(init, options);
|
||||||
|
const res = await fetch(input, nextInit);
|
||||||
|
|
||||||
|
// 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> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
return "status" in source && ("data" in source || "message" in source || "errors" in source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeErrors(value: unknown): unknown[] {
|
||||||
|
if (value == null) return [];
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(payload: unknown, envelope: ApiEnvelope<unknown> | null): string | null {
|
||||||
|
const msg =
|
||||||
|
(typeof envelope?.message === "string" && envelope.message.trim()) ||
|
||||||
|
(typeof (payload as any)?.message === "string" && String((payload as any).message).trim());
|
||||||
|
if (msg) return msg;
|
||||||
|
const errors = envelope?.errors ?? (payload as any)?.errors;
|
||||||
|
if (typeof errors === "string" && errors.trim()) return errors.trim();
|
||||||
|
if (Array.isArray(errors) && typeof errors[0] === "string") return errors[0];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyPayload(payload: unknown): string {
|
||||||
|
if (typeof payload === "string") return payload;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
} catch {
|
||||||
|
return String(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined {
|
||||||
|
const baseInit: RequestInit = {
|
||||||
|
...init,
|
||||||
|
// Always include cookies (BackEndGo sets httpOnly access_token/refresh_token cookies).
|
||||||
|
credentials: init?.credentials ?? "include",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cookie-based auth only.
|
||||||
|
// Keep the function so call sites don't change, but never inject Authorization headers.
|
||||||
|
if (options?.skipAuth) return baseInit;
|
||||||
|
return baseInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryRefreshTokens(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await requestJson(
|
||||||
|
API_ENDPOINTS.authRefresh,
|
||||||
|
{ method: "POST" },
|
||||||
|
{ skipAuth: true, skipRefresh: true }
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/uhm/api/sections.ts
Normal file
187
src/uhm/api/sections.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
|
||||||
|
import type {
|
||||||
|
CreateCommitInput,
|
||||||
|
CreateSectionInput,
|
||||||
|
EditorLoadResponse,
|
||||||
|
RestoreCommitInput,
|
||||||
|
Section,
|
||||||
|
SectionCommit,
|
||||||
|
SectionState,
|
||||||
|
SectionSubmission,
|
||||||
|
} from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CreateCommitInput,
|
||||||
|
CreateSectionInput,
|
||||||
|
EditorLoadResponse,
|
||||||
|
RestoreCommitInput,
|
||||||
|
Section,
|
||||||
|
SectionCommit,
|
||||||
|
SectionState,
|
||||||
|
SectionSubmission,
|
||||||
|
} from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
// Sections (API cũ) => Projects (API mới)
|
||||||
|
|
||||||
|
export async function fetchSections(): Promise<Section[]> {
|
||||||
|
// /users/current/project requires JWT.
|
||||||
|
return requestJson<Section[]>(API_ENDPOINTS.currentUserProjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSection(input: CreateSectionInput): Promise<Section> {
|
||||||
|
// POST /projects
|
||||||
|
return requestJson<Section>(API_ENDPOINTS.projects, jsonRequestInit("POST", input));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSectionEditor(sectionId: string): Promise<EditorLoadResponse> {
|
||||||
|
// API mới không có endpoint "editor". FE tự load:
|
||||||
|
// 1) Project details
|
||||||
|
// 2) Project commits (to get snapshot_json of latest commit)
|
||||||
|
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||||
|
const commits = await fetchSectionCommits(sectionId);
|
||||||
|
|
||||||
|
const headCommitId = project.latest_commit_id ?? null;
|
||||||
|
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
||||||
|
const snapshot = headCommit?.snapshot_json ?? null;
|
||||||
|
|
||||||
|
const state: SectionState = {
|
||||||
|
status: project.project_status || "ACTIVE",
|
||||||
|
head_commit_id: headCommitId,
|
||||||
|
locked_by: project.locked_by ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
section: project,
|
||||||
|
state,
|
||||||
|
commit: headCommit,
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSectionCommit(
|
||||||
|
sectionId: string,
|
||||||
|
input: CreateCommitInput
|
||||||
|
): Promise<{ commit: SectionCommit; state: SectionState }> {
|
||||||
|
// POST /projects/{id}/commits
|
||||||
|
const commit = await requestJson<SectionCommit>(
|
||||||
|
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`,
|
||||||
|
jsonRequestInit("POST", {
|
||||||
|
snapshot_json: input.snapshot,
|
||||||
|
edit_summary: input.edit_summary,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh project state (latest_commit_id may have moved).
|
||||||
|
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||||
|
const state: SectionState = {
|
||||||
|
status: project.project_status || "ACTIVE",
|
||||||
|
head_commit_id: project.latest_commit_id ?? null,
|
||||||
|
locked_by: project.locked_by ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { commit, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSectionCommits(sectionId: string): Promise<SectionCommit[]> {
|
||||||
|
return requestJson<SectionCommit[]>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreSectionCommit(
|
||||||
|
sectionId: string,
|
||||||
|
input: RestoreCommitInput
|
||||||
|
): Promise<{ commit: SectionCommit | null; state: SectionState }> {
|
||||||
|
// POST /projects/{id}/commits/restore
|
||||||
|
await requestJson(
|
||||||
|
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits/restore`,
|
||||||
|
jsonRequestInit("POST", { commit_id: input.commit_id })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload commits + project to determine new head commit.
|
||||||
|
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||||
|
const commits = await fetchSectionCommits(sectionId);
|
||||||
|
const headCommitId = project.latest_commit_id ?? null;
|
||||||
|
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
|
||||||
|
|
||||||
|
const state: SectionState = {
|
||||||
|
status: project.project_status || "ACTIVE",
|
||||||
|
head_commit_id: headCommitId,
|
||||||
|
locked_by: project.locked_by ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { commit: headCommit, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitSection(sectionId: string): Promise<SectionSubmission> {
|
||||||
|
// Submit latest commit of project
|
||||||
|
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
|
||||||
|
const commitId = project.latest_commit_id;
|
||||||
|
if (!commitId) {
|
||||||
|
throw new Error("Project has no latest commit to submit");
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestJson<SectionSubmission>(
|
||||||
|
API_ENDPOINTS.submissions,
|
||||||
|
jsonRequestInit("POST", {
|
||||||
|
project_id: sectionId,
|
||||||
|
commit_id: commitId,
|
||||||
|
content: "",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API mới không có list submissions theo project kèm snapshot.
|
||||||
|
// FE dùng /submissions (admin/mod) hoặc fetch từng submission id.
|
||||||
|
export async function fetchSectionSubmissions(_sectionId: string): Promise<SectionSubmission[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchSubmissions(query?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
statuses?: string[];
|
||||||
|
project_id?: string;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<SectionSubmission[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query?.page) params.set("page", String(Math.trunc(query.page)));
|
||||||
|
if (query?.limit) params.set("limit", String(Math.trunc(query.limit)));
|
||||||
|
if (query?.project_id) params.set("project_id", query.project_id);
|
||||||
|
if (query?.search) params.set("search", query.search);
|
||||||
|
if (query?.statuses?.length) {
|
||||||
|
for (const s of query.statuses) params.append("statuses", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = params.toString();
|
||||||
|
const url = suffix ? `${API_ENDPOINTS.submissions}?${suffix}` : API_ENDPOINTS.submissions;
|
||||||
|
return requestJson<SectionSubmission[]>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveSubmission(
|
||||||
|
submissionId: string,
|
||||||
|
input: { review_note: string }
|
||||||
|
): Promise<SectionSubmission> {
|
||||||
|
return requestJson<SectionSubmission>(
|
||||||
|
`${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}/status`,
|
||||||
|
jsonRequestInit("PATCH", {
|
||||||
|
status: "APPROVED",
|
||||||
|
review_note: input.review_note,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectSubmission(
|
||||||
|
submissionId: string,
|
||||||
|
input: { review_note: string }
|
||||||
|
): Promise<SectionSubmission> {
|
||||||
|
return requestJson<SectionSubmission>(
|
||||||
|
`${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}/status`,
|
||||||
|
jsonRequestInit("PATCH", {
|
||||||
|
status: "REJECTED",
|
||||||
|
review_note: input.review_note,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience for runtime logs/debug: expose effective base.
|
||||||
|
export const EFFECTIVE_API_BASE_URL = API_BASE_URL;
|
||||||
20
src/uhm/api/tiles.ts
Normal file
20
src/uhm/api/tiles.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { requestJson } from "@/uhm/api/http";
|
||||||
|
|
||||||
|
export type TileMetadata = Record<string, string>;
|
||||||
|
|
||||||
|
export function getVectorTileTemplateUrl(): string {
|
||||||
|
return API_ENDPOINTS.vectorTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRasterTileTemplateUrl(): string {
|
||||||
|
return API_ENDPOINTS.rasterTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVectorTilesMetadata(): Promise<TileMetadata> {
|
||||||
|
return requestJson<TileMetadata>(API_ENDPOINTS.vectorTilesMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRasterTilesMetadata(): Promise<TileMetadata> {
|
||||||
|
return requestJson<TileMetadata>(API_ENDPOINTS.rasterTilesMetadata);
|
||||||
|
}
|
||||||
9
src/uhm/components/AuthPanel.tsx
Normal file
9
src/uhm/components/AuthPanel.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// FrontEndAdmin 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;
|
||||||
|
}
|
||||||
|
|
||||||
95
src/uhm/components/BackgroundLayersPanel.tsx
Normal file
95
src/uhm/components/BackgroundLayersPanel.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
|
BackgroundLayerId,
|
||||||
|
BackgroundLayerVisibility,
|
||||||
|
} from "@/uhm/lib/backgroundLayers";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
visibility: BackgroundLayerVisibility;
|
||||||
|
onToggleLayer: (id: BackgroundLayerId) => void;
|
||||||
|
onShowAll: () => void;
|
||||||
|
onHideAll: () => void;
|
||||||
|
topContent?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BackgroundLayersPanel({
|
||||||
|
visibility,
|
||||||
|
onToggleLayer,
|
||||||
|
onShowAll,
|
||||||
|
onHideAll,
|
||||||
|
topContent,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
width: "240px",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#e5e7eb",
|
||||||
|
borderLeft: "1px solid #1f2937",
|
||||||
|
padding: "12px",
|
||||||
|
height: "100vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topContent ? <div style={{ marginBottom: "12px" }}>{topContent}</div> : null}
|
||||||
|
|
||||||
|
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "8px", marginBottom: "12px" }}>
|
||||||
|
<button
|
||||||
|
onClick={onShowAll}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "6px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#374151",
|
||||||
|
color: "#f9fafb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bật hết
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onHideAll}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "6px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#1f2937",
|
||||||
|
color: "#f9fafb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tắt hết
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gap: "8px" }}>
|
||||||
|
{BACKGROUND_LAYER_OPTIONS.map((layer) => (
|
||||||
|
<label
|
||||||
|
key={layer.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
fontSize: "14px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibility[layer.id]}
|
||||||
|
onChange={() => onToggleLayer(layer.id)}
|
||||||
|
/>
|
||||||
|
<span>{layer.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
349
src/uhm/components/CommitTreePopup.tsx
Normal file
349
src/uhm/components/CommitTreePopup.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, type ComponentProps } from "react";
|
||||||
|
import Tree from "react-d3-tree";
|
||||||
|
|
||||||
|
export type CommitTreeItem = {
|
||||||
|
id: string;
|
||||||
|
parent_commit_id: string | null;
|
||||||
|
restored_from_commit_id: string | null;
|
||||||
|
commit_no: number;
|
||||||
|
kind: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
title: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommitTreeNode = {
|
||||||
|
commit: CommitTreeItem;
|
||||||
|
children: CommitTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommitTreeDatum = {
|
||||||
|
name: string;
|
||||||
|
commit: CommitTreeItem;
|
||||||
|
isHead: boolean;
|
||||||
|
detail: string;
|
||||||
|
restoredFromLabel: string | null;
|
||||||
|
children?: CommitTreeDatum[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
commits: CommitTreeItem[];
|
||||||
|
headCommitId: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TreeRenderNode = NonNullable<ComponentProps<typeof Tree>["renderCustomNodeElement"]>;
|
||||||
|
|
||||||
|
export default function CommitTreePopup({
|
||||||
|
open,
|
||||||
|
commits,
|
||||||
|
headCommitId,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const { roots, commitById } = useMemo(() => buildCommitTree(commits), [commits]);
|
||||||
|
const treeData = useMemo(
|
||||||
|
() => roots.map((node) => toTreeDatum(node, commitById, headCommitId)),
|
||||||
|
[roots, commitById, headCommitId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
background: "rgba(2, 6, 23, 0.72)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Commit tree"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "min(1120px, calc(100vw - 48px))",
|
||||||
|
maxHeight: "min(720px, calc(100vh - 48px))",
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
boxShadow: "0 24px 80px rgba(0, 0, 0, 0.45)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.commit-tree-link {
|
||||||
|
fill: none;
|
||||||
|
stroke: #ffffff;
|
||||||
|
stroke-width: 4px;
|
||||||
|
stroke-opacity: 1;
|
||||||
|
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.75));
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "12px",
|
||||||
|
padding: "14px 16px",
|
||||||
|
borderBottom: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
|
||||||
|
Commit tree
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "3px", fontSize: "12px", color: "#94a3b8" }}>
|
||||||
|
{commits.length} commit{commits.length === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: "7px 10px",
|
||||||
|
border: "1px solid #475569",
|
||||||
|
borderRadius: "4px",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#f8fafc",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{treeData.length === 0 ? (
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "14px" }}>
|
||||||
|
Chưa có commit.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minWidth: "640px",
|
||||||
|
height: "540px",
|
||||||
|
border: "1px solid #64748b",
|
||||||
|
borderRadius: "6px",
|
||||||
|
background: "#111827",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tree
|
||||||
|
data={treeData}
|
||||||
|
orientation="vertical"
|
||||||
|
translate={{ x: 520, y: 56 }}
|
||||||
|
nodeSize={{ x: 300, y: 165 }}
|
||||||
|
separation={{ siblings: 1.15, nonSiblings: 1.45 }}
|
||||||
|
pathFunc="step"
|
||||||
|
collapsible={false}
|
||||||
|
zoomable
|
||||||
|
draggable
|
||||||
|
scaleExtent={{ min: 0.45, max: 1.4 }}
|
||||||
|
renderCustomNodeElement={renderCommitTreeNode}
|
||||||
|
pathClassFunc={() => "commit-tree-link"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCommitTreeNode: TreeRenderNode = function renderCommitTreeNode({ nodeDatum }) {
|
||||||
|
const datum = nodeDatum as unknown as CommitTreeDatum;
|
||||||
|
const commit = datum.commit;
|
||||||
|
const isHead = datum.isHead;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<circle
|
||||||
|
r={8}
|
||||||
|
fill={isHead ? "#16a34a" : "#111827"}
|
||||||
|
stroke={isHead ? "#bbf7d0" : "#f8fafc"}
|
||||||
|
strokeWidth={3}
|
||||||
|
/>
|
||||||
|
<foreignObject x={-115} y={18} width={230} height={96}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "220px",
|
||||||
|
minHeight: "78px",
|
||||||
|
padding: "8px 9px",
|
||||||
|
border: isHead ? "2px solid #86efac" : "2px solid #e2e8f0",
|
||||||
|
borderRadius: "6px",
|
||||||
|
background: isHead ? "#14532d" : "#1f2937",
|
||||||
|
color: "#f8fafc",
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: 1.35,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#f8fafc", fontWeight: 700 }}>
|
||||||
|
#{commit.commit_no}
|
||||||
|
</span>
|
||||||
|
{isHead ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "1px 5px",
|
||||||
|
border: "1px solid #22c55e",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#bbf7d0",
|
||||||
|
fontSize: "10px",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
HEAD
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
title={formatCommitTitle(commit)}
|
||||||
|
style={{
|
||||||
|
marginTop: "4px",
|
||||||
|
color: "#f8fafc",
|
||||||
|
fontWeight: 700,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCommitTitle(commit)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "4px", color: "#94a3b8" }}>
|
||||||
|
{datum.detail}
|
||||||
|
</div>
|
||||||
|
{datum.restoredFromLabel ? (
|
||||||
|
<div
|
||||||
|
title={datum.restoredFromLabel}
|
||||||
|
style={{
|
||||||
|
marginTop: "3px",
|
||||||
|
color: "#93c5fd",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{datum.restoredFromLabel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildCommitTree(commits: CommitTreeItem[]) {
|
||||||
|
const commitById = new Map<string, CommitTreeItem>();
|
||||||
|
const nodeById = new Map<string, CommitTreeNode>();
|
||||||
|
|
||||||
|
for (const commit of commits) {
|
||||||
|
commitById.set(commit.id, commit);
|
||||||
|
nodeById.set(commit.id, { commit, children: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: CommitTreeNode[] = [];
|
||||||
|
for (const node of nodeById.values()) {
|
||||||
|
const parentId = getDisplayParentCommitId(node.commit);
|
||||||
|
const parent = parentId ? nodeById.get(parentId) : null;
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortNodes = (nodes: CommitTreeNode[]) => {
|
||||||
|
nodes.sort((a, b) => a.commit.commit_no - b.commit.commit_no);
|
||||||
|
for (const node of nodes) {
|
||||||
|
sortNodes(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sortNodes(roots);
|
||||||
|
|
||||||
|
return { roots, commitById };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTreeDatum(
|
||||||
|
node: CommitTreeNode,
|
||||||
|
commitById: Map<string, CommitTreeItem>,
|
||||||
|
headCommitId: string | null
|
||||||
|
): CommitTreeDatum {
|
||||||
|
const commit = node.commit;
|
||||||
|
const restoredFromCommit = commit.restored_from_commit_id
|
||||||
|
? commitById.get(commit.restored_from_commit_id) || null
|
||||||
|
: null;
|
||||||
|
const children = node.children.map((child) => toTreeDatum(child, commitById, headCommitId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: formatCommitTitle(commit),
|
||||||
|
commit,
|
||||||
|
isHead: headCommitId === commit.id,
|
||||||
|
detail: `${commit.kind} by ${commit.created_by} - ${formatDateTime(commit.created_at)}`,
|
||||||
|
restoredFromLabel: restoredFromCommit
|
||||||
|
? `restored from #${restoredFromCommit.commit_no} ${formatCommitTitle(restoredFromCommit)}`
|
||||||
|
: null,
|
||||||
|
children: children.length ? children : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayParentCommitId(commit: CommitTreeItem): string | null {
|
||||||
|
if (commit.kind === "restore" && commit.restored_from_commit_id) {
|
||||||
|
return commit.restored_from_commit_id;
|
||||||
|
}
|
||||||
|
return commit.parent_commit_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommitTitle(commit: CommitTreeItem): string {
|
||||||
|
return commit.title?.trim() || `Commit #${commit.commit_no}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
491
src/uhm/components/Editor.tsx
Normal file
491
src/uhm/components/Editor.tsx
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { UndoAction } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: EditorMode;
|
||||||
|
setMode: (mode: EditorMode) => void;
|
||||||
|
entityStatus?: string | null;
|
||||||
|
onUndo: () => void;
|
||||||
|
onCommit: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onRestoreCommit: (commitId: string) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
sectionTitle: string;
|
||||||
|
sectionStatus: string;
|
||||||
|
commitTitle: string;
|
||||||
|
commitNote: string;
|
||||||
|
onCommitTitleChange: (title: string) => void;
|
||||||
|
onCommitNoteChange: (note: string) => void;
|
||||||
|
commitCount: number;
|
||||||
|
hasHeadCommit: boolean;
|
||||||
|
headCommitId: string | null;
|
||||||
|
latestCommitLabel: string | null;
|
||||||
|
commits: Array<{
|
||||||
|
id: string;
|
||||||
|
created_at?: string;
|
||||||
|
edit_summary: string;
|
||||||
|
user_id: string;
|
||||||
|
}>;
|
||||||
|
changesCount: number;
|
||||||
|
undoStack: UndoAction[];
|
||||||
|
createdEntities: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type_id?: string | null;
|
||||||
|
}>;
|
||||||
|
createdGeometries: Array<{
|
||||||
|
id: string | number;
|
||||||
|
geometryType: string;
|
||||||
|
semanticType?: string | null;
|
||||||
|
entityNames: string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Editor({
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
entityStatus,
|
||||||
|
onUndo,
|
||||||
|
onCommit,
|
||||||
|
onSubmit,
|
||||||
|
onRestoreCommit,
|
||||||
|
isSaving,
|
||||||
|
isSubmitting,
|
||||||
|
sectionTitle,
|
||||||
|
sectionStatus,
|
||||||
|
commitTitle,
|
||||||
|
commitNote,
|
||||||
|
onCommitTitleChange,
|
||||||
|
onCommitNoteChange,
|
||||||
|
commitCount,
|
||||||
|
hasHeadCommit,
|
||||||
|
headCommitId,
|
||||||
|
latestCommitLabel,
|
||||||
|
commits,
|
||||||
|
changesCount,
|
||||||
|
undoStack,
|
||||||
|
createdEntities,
|
||||||
|
createdGeometries,
|
||||||
|
}: Props) {
|
||||||
|
const formatCommitTitle = (commit: Props["commits"][number]) =>
|
||||||
|
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
|
||||||
|
|
||||||
|
const toggleMode = (newMode: EditorMode) => {
|
||||||
|
if (mode === newMode) {
|
||||||
|
setMode("idle"); // bấm lại → tắt
|
||||||
|
} else {
|
||||||
|
setMode(newMode); // chuyển mode
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lấy tối đa 8 tác vụ mới nhất, bỏ trùng nhãn (cùng loại/cùng id)
|
||||||
|
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();
|
||||||
|
})();
|
||||||
|
|
||||||
|
const getButtonStyle = (btnMode: EditorMode) => ({
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: mode === btnMode ? "#4caf50" : "#222",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: "4px",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "220px",
|
||||||
|
height: "100vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "#111",
|
||||||
|
color: "white",
|
||||||
|
padding: "12px",
|
||||||
|
borderRight: "1px solid #333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginBottom: "10px" }}>Editor</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "12px",
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#cbd5e1",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "white", fontWeight: 600 }}>{sectionTitle}</div>
|
||||||
|
<div style={{ marginTop: "4px" }}>Status: {sectionStatus}</div>
|
||||||
|
<div>Commits: {commitCount}</div>
|
||||||
|
<div>{latestCommitLabel || "Chưa có commit"}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "12px",
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#cbd5e1",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: "8px", fontWeight: 600, color: "white" }}>Project</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={getButtonStyle("draw")}
|
||||||
|
onClick={() => toggleMode("draw")}
|
||||||
|
>
|
||||||
|
Draw
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={getButtonStyle("select")}
|
||||||
|
onClick={() => toggleMode("select")}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={getButtonStyle("idle")}
|
||||||
|
onClick={() => setMode("idle")}
|
||||||
|
>
|
||||||
|
Idle
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={getButtonStyle("add-point")}
|
||||||
|
onClick={() => setMode("add-point")}
|
||||||
|
>
|
||||||
|
Add point
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={getButtonStyle("add-line")}
|
||||||
|
onClick={() => setMode("add-line")}
|
||||||
|
>
|
||||||
|
Add line
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={getButtonStyle("add-path")}
|
||||||
|
onClick={() => setMode("add-path")}
|
||||||
|
>
|
||||||
|
Add path
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={getButtonStyle("add-circle")}
|
||||||
|
onClick={() => setMode("add-circle")}
|
||||||
|
>
|
||||||
|
Add circle
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "12px", fontSize: "14px" }}>
|
||||||
|
Mode: <b>{mode}</b>
|
||||||
|
</div>
|
||||||
|
{mode === "add-line" ? (
|
||||||
|
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
|
||||||
|
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{mode === "add-path" ? (
|
||||||
|
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
|
||||||
|
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{mode === "add-circle" ? (
|
||||||
|
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
|
||||||
|
Giữ chuột trái kéo để mở bán kính, thả chuột để hoàn tất.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{entityStatus ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "12px",
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
color: "#fca5a5",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entityStatus}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div style={{ marginTop: "12px" }}>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#334155",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
onClick={onUndo}
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={commitTitle}
|
||||||
|
onChange={(event) => onCommitTitleChange(event.target.value)}
|
||||||
|
placeholder="Commit title"
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
marginTop: "8px",
|
||||||
|
padding: "7px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "white",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={commitNote}
|
||||||
|
onChange={(event) => onCommitNoteChange(event.target.value)}
|
||||||
|
placeholder="Commit note"
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
marginTop: "8px",
|
||||||
|
padding: "7px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "white",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
resize: "vertical",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 68px", gap: "8px", marginTop: "8px" }}>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "none",
|
||||||
|
cursor: isSaving || isSubmitting ? "not-allowed" : "pointer",
|
||||||
|
background: isSaving || isSubmitting ? "#555" : "#0f766e",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
onClick={onCommit}
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
>
|
||||||
|
Commit ({changesCount})
|
||||||
|
</button>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
marginTop: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "none",
|
||||||
|
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
|
||||||
|
background: isSubmitting || !hasHeadCommit ? "#555" : "#16a34a",
|
||||||
|
color: "white",
|
||||||
|
opacity: !hasHeadCommit ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={isSubmitting || !hasHeadCommit}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "16px",
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
|
||||||
|
Commit history
|
||||||
|
</div>
|
||||||
|
{commits.length === 0 ? (
|
||||||
|
<div style={{ color: "#64748b", fontSize: "12px" }}>
|
||||||
|
Chưa có commit
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
|
||||||
|
{commits.slice(0, 8).map((commit) => (
|
||||||
|
<li
|
||||||
|
key={commit.id}
|
||||||
|
style={{
|
||||||
|
padding: "6px 0",
|
||||||
|
borderBottom: "1px solid #1f2937",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
title={formatCommitTitle(commit)}
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#f8fafc",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCommitTitle(commit)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "2px", color: "#94a3b8" }}>
|
||||||
|
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
marginTop: "4px",
|
||||||
|
padding: "4px 6px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "none",
|
||||||
|
background: "#334155",
|
||||||
|
color: "white",
|
||||||
|
cursor: isSaving || isSubmitting ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => onRestoreCommit(commit.id)}
|
||||||
|
disabled={isSaving || isSubmitting}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "16px",
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: "6px", fontWeight: 600, fontSize: "14px" }}>
|
||||||
|
Tác vụ có thể undo ({recentUndoLabels.length})
|
||||||
|
</div>
|
||||||
|
{recentUndoLabels.length === 0 ? (
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "13px" }}>Chưa có thao tác</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "13px", color: "#e2e8f0" }}>
|
||||||
|
{recentUndoLabels.map((label, idx) => (
|
||||||
|
<li key={`${label}-${idx}`} style={{ padding: "4px 0", borderBottom: "1px solid #1f2937" }}>
|
||||||
|
{label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "16px",
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
|
||||||
|
Mới tạo trong phiên
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
|
||||||
|
Entities ({createdEntities.length})
|
||||||
|
</div>
|
||||||
|
{createdEntities.length === 0 ? (
|
||||||
|
<div style={{ color: "#64748b", fontSize: "12px", marginBottom: "10px" }}>
|
||||||
|
Chưa tạo entity mới
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px", marginBottom: "10px" }}>
|
||||||
|
{createdEntities.map((entity) => (
|
||||||
|
<li
|
||||||
|
key={entity.id}
|
||||||
|
style={{
|
||||||
|
padding: "4px 0",
|
||||||
|
borderBottom: "1px solid #1f2937",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
}}
|
||||||
|
title={entity.id}
|
||||||
|
>
|
||||||
|
{entity.name} ({entity.type_id || "country"})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
|
||||||
|
Geometries mới chưa commit ({createdGeometries.length})
|
||||||
|
</div>
|
||||||
|
{createdGeometries.length === 0 ? (
|
||||||
|
<div style={{ color: "#64748b", fontSize: "12px" }}>
|
||||||
|
Chưa có geometry mới chờ commit
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
|
||||||
|
{createdGeometries.map((geometry) => (
|
||||||
|
<li
|
||||||
|
key={String(geometry.id)}
|
||||||
|
style={{
|
||||||
|
padding: "4px 0",
|
||||||
|
borderBottom: "1px solid #1f2937",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{geometry.id} [{geometry.geometryType}] {geometry.semanticType ? `- ${geometry.semanticType}` : ""}
|
||||||
|
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
default:
|
||||||
|
return "Tác vụ";
|
||||||
|
}
|
||||||
|
}
|
||||||
1648
src/uhm/components/Map.tsx
Normal file
1648
src/uhm/components/Map.tsx
Normal file
File diff suppressed because it is too large
Load Diff
493
src/uhm/components/SelectedGeometryPanel.tsx
Normal file
493
src/uhm/components/SelectedGeometryPanel.tsx
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type CSSProperties } from "react";
|
||||||
|
import { Entity } from "@/uhm/api/entities";
|
||||||
|
import { Feature } from "@/uhm/lib/useEditorState";
|
||||||
|
import {
|
||||||
|
EntityGeometryPreset,
|
||||||
|
EntityTypeGroupId,
|
||||||
|
EntityTypeOption,
|
||||||
|
findEntityTypeOption,
|
||||||
|
groupEntityTypeOptions,
|
||||||
|
} from "@/uhm/lib/entityTypeOptions";
|
||||||
|
import type { EntityFormState, GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedFeature: Feature | null;
|
||||||
|
selectedFeatureEntitySummary: string;
|
||||||
|
selectedFeatureBindingSummary: string;
|
||||||
|
entities: Entity[];
|
||||||
|
selectedGeometryEntityIds: string[];
|
||||||
|
onEntityIdsChange: (values: string[]) => void;
|
||||||
|
entitySearchQuery: string;
|
||||||
|
onEntitySearchQueryChange: (value: string) => void;
|
||||||
|
entitySearchResults: Entity[];
|
||||||
|
selectedSearchEntityId: string | null;
|
||||||
|
onSelectSearchEntityId: (value: string | null) => void;
|
||||||
|
onAddSelectedSearchEntity: () => void;
|
||||||
|
isEntitySearchLoading: boolean;
|
||||||
|
entityForm: EntityFormState;
|
||||||
|
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
||||||
|
entityTypeOptions: EntityTypeOption[];
|
||||||
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
|
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||||
|
isEntitySubmitting: boolean;
|
||||||
|
onCreateEntityOnly: () => void;
|
||||||
|
onApplyGeometryMetadata: () => void;
|
||||||
|
onApplyEntitiesForSelectedGeometry: () => void;
|
||||||
|
changeCount: number;
|
||||||
|
entityFormStatus: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SelectedGeometryPanel({
|
||||||
|
selectedFeature,
|
||||||
|
selectedFeatureEntitySummary,
|
||||||
|
selectedFeatureBindingSummary,
|
||||||
|
entities,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
onEntityIdsChange,
|
||||||
|
entitySearchQuery,
|
||||||
|
onEntitySearchQueryChange,
|
||||||
|
entitySearchResults,
|
||||||
|
selectedSearchEntityId,
|
||||||
|
onSelectSearchEntityId,
|
||||||
|
onAddSelectedSearchEntity,
|
||||||
|
isEntitySearchLoading,
|
||||||
|
entityForm,
|
||||||
|
onEntityFormChange,
|
||||||
|
entityTypeOptions,
|
||||||
|
geometryMetaForm,
|
||||||
|
onGeometryMetaFormChange,
|
||||||
|
isEntitySubmitting,
|
||||||
|
onCreateEntityOnly,
|
||||||
|
onApplyGeometryMetadata,
|
||||||
|
onApplyEntitiesForSelectedGeometry,
|
||||||
|
changeCount,
|
||||||
|
entityFormStatus,
|
||||||
|
}: Props) {
|
||||||
|
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
|
||||||
|
const featureGeometryPreset = selectedFeature
|
||||||
|
? resolveFeatureGeometryPreset(selectedFeature)
|
||||||
|
: null;
|
||||||
|
const allowedGroupIds = featureGeometryPreset
|
||||||
|
? getAllowedGroupIdsForPreset(featureGeometryPreset)
|
||||||
|
: [];
|
||||||
|
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
||||||
|
allowedGroupIds.includes(group.id)
|
||||||
|
);
|
||||||
|
const groupedEntityTypeOptionsForCreate = selectedFeature
|
||||||
|
? visibleGroupedEntityTypeOptions
|
||||||
|
: groupedEntityTypeOptions;
|
||||||
|
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
|
||||||
|
const hasCurrentVisibleTypeOption = groupedEntityTypeOptionsForCreate.some((group) =>
|
||||||
|
group.options.some((option) => option.value === entityForm.type_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
background: "#0b1220",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #1f2937",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
|
||||||
|
Entity & Geometry
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedFeature ? (
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
|
||||||
|
Chưa chọn geometry. Tạo entity mới ở khối bên dưới, hoặc vào mode Select để bind entity cho geometry.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #243244",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Thuộc tính GEO
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
||||||
|
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={geometryMetaForm.time_start}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
||||||
|
placeholder="time_start"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={geometryMetaForm.time_end}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
|
||||||
|
placeholder="time_end"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onApplyGeometryMetadata}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={primaryGeometryButtonStyle}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #1f3b5a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Bind entity có sẵn
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||||
|
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng.
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={entitySearchQuery}
|
||||||
|
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
|
||||||
|
placeholder="Search entity theo name..."
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={selectedSearchEntityId || ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
onSelectSearchEntityId(event.target.value ? event.target.value : null)
|
||||||
|
}
|
||||||
|
disabled={isEntitySubmitting || isEntitySearchLoading}
|
||||||
|
style={entityInputStyle}
|
||||||
|
>
|
||||||
|
<option value="">-- Chọn entity từ kết quả search --</option>
|
||||||
|
{entitySearchResults.map((entity) => (
|
||||||
|
<option key={entity.id} value={entity.id}>
|
||||||
|
{entity.name} ({entity.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddSelectedSearchEntity}
|
||||||
|
disabled={isEntitySubmitting || isEntitySearchLoading}
|
||||||
|
style={secondaryActionButtonStyle}
|
||||||
|
>
|
||||||
|
Thêm entity đã chọn vào danh sách gắn
|
||||||
|
</button>
|
||||||
|
{isEntitySearchLoading ? (
|
||||||
|
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
|
||||||
|
Đang tìm entity...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
onClick={onApplyEntitiesForSelectedGeometry}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||||
|
background: "#0f766e",
|
||||||
|
color: "#ffffff",
|
||||||
|
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Áp dụng danh sách entity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{changeCount > 0 ? (
|
||||||
|
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||||
|
Thay đổi sẽ vào lịch sử khi Commit.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #1e3a8a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
marginTop: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Tạo entity mới (độc lập)
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||||
|
Chỉ tạo entity, không tự bind vào geometry.
|
||||||
|
</div>
|
||||||
|
{selectedFeature ? (
|
||||||
|
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||||
|
Type đang bị giới hạn theo geometry: <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={entityForm.name}
|
||||||
|
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
||||||
|
placeholder="Tên entity mới"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={entityForm.slug}
|
||||||
|
onChange={(event) => onEntityFormChange("slug", event.target.value)}
|
||||||
|
placeholder="Slug"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||||
|
Chọn loại entity
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={entityForm.type_id}
|
||||||
|
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
>
|
||||||
|
{!selectedFeature && !hasCurrentVisibleTypeOption && entityForm.type_id ? (
|
||||||
|
<option value={entityForm.type_id}>
|
||||||
|
Custom Type ({entityForm.type_id})
|
||||||
|
</option>
|
||||||
|
) : null}
|
||||||
|
{groupedEntityTypeOptionsForCreate.map((group) => (
|
||||||
|
<optgroup
|
||||||
|
key={group.id}
|
||||||
|
label={`${group.label} (${group.geometryLabel})`}
|
||||||
|
>
|
||||||
|
{group.options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{selectedTypeOption ? (
|
||||||
|
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||||
|
Type đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
|
||||||
|
</div>
|
||||||
|
) : entityForm.type_id ? (
|
||||||
|
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||||
|
Type đang chọn: <b>{entityForm.type_id}</b>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onCreateEntityOnly}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||||
|
background: "#2563eb",
|
||||||
|
color: "#ffffff",
|
||||||
|
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tạo entity mới
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entityFormStatus ? (
|
||||||
|
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
|
||||||
|
{entityFormStatus}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityInputStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#f8fafc",
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: "13px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeButtonStyle: CSSProperties = {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#7f1d1d",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "12px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryActionButtonStyle: CSSProperties = {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#1d4ed8",
|
||||||
|
color: "#ffffff",
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryGeometryButtonStyle: CSSProperties = {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#0f766e",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
|
||||||
|
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
|
||||||
|
if (explicitPreset) return explicitPreset;
|
||||||
|
|
||||||
|
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
||||||
|
if (semanticType) {
|
||||||
|
const option = findEntityTypeOption(semanticType);
|
||||||
|
if (option) return option.geometryPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapGeometryTypeToPreset(feature.geometry.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
normalized === "point" ||
|
||||||
|
normalized === "line" ||
|
||||||
|
normalized === "polygon" ||
|
||||||
|
normalized === "circle-area"
|
||||||
|
) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTypeId(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized.length ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGeometryTypeToPreset(
|
||||||
|
geometryType: Feature["geometry"]["type"]
|
||||||
|
): EntityGeometryPreset {
|
||||||
|
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||||
|
return "point";
|
||||||
|
}
|
||||||
|
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||||
|
return "line";
|
||||||
|
}
|
||||||
|
return "polygon";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedGroupIdsForPreset(
|
||||||
|
geometryPreset: EntityGeometryPreset
|
||||||
|
): EntityTypeGroupId[] {
|
||||||
|
if (geometryPreset === "point") {
|
||||||
|
return ["point"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometryPreset === "line") {
|
||||||
|
return ["line"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometryPreset === "circle-area") {
|
||||||
|
return ["circle"];
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
140
src/uhm/components/TimelineBar.tsx
Normal file
140
src/uhm/components/TimelineBar.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/timeline";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
year: number;
|
||||||
|
onYearChange: (year: number) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
statusText?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TimelineBar({
|
||||||
|
year,
|
||||||
|
onYearChange,
|
||||||
|
isLoading,
|
||||||
|
disabled,
|
||||||
|
statusText,
|
||||||
|
}: Props) {
|
||||||
|
const lower = FIXED_TIMELINE_START_YEAR;
|
||||||
|
const upper = FIXED_TIMELINE_END_YEAR;
|
||||||
|
const effectiveDisabled = disabled;
|
||||||
|
const safeYear = clampYearValue(year, lower, upper);
|
||||||
|
|
||||||
|
const helperText = isLoading
|
||||||
|
? "Đang tải geometry theo mốc thời gian..."
|
||||||
|
: statusText || "Kéo thanh hoặc nhập số năm để query chính xác.";
|
||||||
|
|
||||||
|
const handleYearChange = (nextYear: number) => {
|
||||||
|
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "18px",
|
||||||
|
right: "18px",
|
||||||
|
bottom: "16px",
|
||||||
|
zIndex: 10,
|
||||||
|
background: "rgba(15, 23, 42, 0.9)",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.3)",
|
||||||
|
borderRadius: "10px",
|
||||||
|
padding: "12px 14px",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "8px",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "13px", fontWeight: 600, letterSpacing: "0.02em" }}>
|
||||||
|
Timeline
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
|
||||||
|
{formatYear(safeYear)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: "12px", color: "#cbd5e1", marginTop: "8px", marginBottom: "6px" }}>
|
||||||
|
Mốc thời gian chi tiết
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "minmax(0, 1fr) 120px",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={lower}
|
||||||
|
max={upper}
|
||||||
|
step={1}
|
||||||
|
value={safeYear}
|
||||||
|
onChange={(event) => handleYearChange(Number(event.target.value))}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
aria-label="Timeline year"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
accentColor: "#22c55e",
|
||||||
|
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: effectiveDisabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={lower}
|
||||||
|
max={upper}
|
||||||
|
step={1}
|
||||||
|
value={safeYear}
|
||||||
|
onChange={(event) => handleYearChange(Number(event.target.value))}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
aria-label="Timeline exact year"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "8px",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr auto 1fr",
|
||||||
|
alignItems: "center",
|
||||||
|
columnGap: "10px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#94a3b8" }}>{formatYear(lower)}</span>
|
||||||
|
<span style={{ color: "#cbd5e1", textAlign: "center", whiteSpace: "nowrap" }}>
|
||||||
|
{helperText}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(upper)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYear(year: number): string {
|
||||||
|
if (year < 0) {
|
||||||
|
return `${Math.abs(year)} TCN`;
|
||||||
|
}
|
||||||
|
return `${year}`;
|
||||||
|
}
|
||||||
28
src/uhm/lib/backgroundLayers.ts
Normal file
28
src/uhm/lib/backgroundLayers.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export const BACKGROUND_LAYER_OPTIONS = [
|
||||||
|
{ id: "raster-base-layer", label: "Raster" },
|
||||||
|
{ id: "graticules-line", label: "Graticules" },
|
||||||
|
{ id: "land", label: "Land" },
|
||||||
|
{ id: "bg-countries-fill", label: "Countries" },
|
||||||
|
{ id: "bg-country-borders-line", label: "Country Borders" },
|
||||||
|
{ id: "regions-line", label: "Regions" },
|
||||||
|
{ id: "lakes-fill", label: "Lakes" },
|
||||||
|
{ id: "rivers-line", label: "Rivers" },
|
||||||
|
{ id: "geolines-line", label: "Geolines" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"];
|
||||||
|
export type BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>;
|
||||||
|
|
||||||
|
// Tạo map visibility mặc định cho toàn bộ background layers.
|
||||||
|
function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility {
|
||||||
|
return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => {
|
||||||
|
acc[option.id] = value;
|
||||||
|
return acc;
|
||||||
|
}, {} as BackgroundLayerVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BACKGROUND_LAYER_VISIBILITY =
|
||||||
|
buildBackgroundLayerVisibility(true);
|
||||||
|
|
||||||
|
export const HIDDEN_BACKGROUND_LAYER_VISIBILITY =
|
||||||
|
buildBackgroundLayerVisibility(false);
|
||||||
58
src/uhm/lib/editor/background/backgroundVisibilityStorage.ts
Normal file
58
src/uhm/lib/editor/background/backgroundVisibilityStorage.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
|
BackgroundLayerVisibility,
|
||||||
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
} from "@/uhm/lib/backgroundLayers";
|
||||||
|
|
||||||
|
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
||||||
|
|
||||||
|
export function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
const normalized = normalizeBackgroundLayerVisibility(parsed);
|
||||||
|
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Load background layer visibility from storage failed", err);
|
||||||
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
|
||||||
|
JSON.stringify(visibility)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Persist background layer visibility failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
|
||||||
|
const source = raw as Record<string, unknown>;
|
||||||
|
const next: BackgroundLayerVisibility = {
|
||||||
|
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||||
|
const value = source[layer.id];
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
next[layer.id] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
55
src/uhm/lib/editor/draft/draftDiff.ts
Normal file
55
src/uhm/lib/editor/draft/draftDiff.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type {
|
||||||
|
Feature,
|
||||||
|
FeatureCollection,
|
||||||
|
FeatureProperties,
|
||||||
|
Geometry,
|
||||||
|
} from "@/uhm/types/geo";
|
||||||
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
|
||||||
|
export const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
|
||||||
|
|
||||||
|
export function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
|
||||||
|
JSON.stringify(a.properties) === JSON.stringify(b.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInitialMap(fc: FeatureCollection) {
|
||||||
|
const map = new Map<FeatureProperties["id"], Feature>();
|
||||||
|
for (const feature of fc.features) {
|
||||||
|
map.set(feature.properties.id, deepClone(feature));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffDraftToInitial(
|
||||||
|
draft: FeatureCollection,
|
||||||
|
initialMap: Map<FeatureProperties["id"], Feature>
|
||||||
|
) {
|
||||||
|
const next = new Map<FeatureProperties["id"], Change>();
|
||||||
|
const seen = new Set<FeatureProperties["id"]>();
|
||||||
|
|
||||||
|
for (const feature of draft.features) {
|
||||||
|
const id = feature.properties.id;
|
||||||
|
seen.add(id);
|
||||||
|
const initialFeature = initialMap.get(id);
|
||||||
|
if (!initialFeature) {
|
||||||
|
next.set(id, { action: "create", feature: deepClone(feature) });
|
||||||
|
} else if (!featureEquals(initialFeature, feature)) {
|
||||||
|
next.set(id, { action: "update", id, geometry: deepClone(feature.geometry) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id] of initialMap.entries()) {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
next.set(id, { action: "delete", id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
15
src/uhm/lib/editor/draft/editorTypes.ts
Normal file
15
src/uhm/lib/editor/draft/editorTypes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type {
|
||||||
|
Feature,
|
||||||
|
FeatureProperties,
|
||||||
|
Geometry,
|
||||||
|
GeometryChange,
|
||||||
|
} from "@/uhm/types/geo";
|
||||||
|
|
||||||
|
export type Change = GeometryChange;
|
||||||
|
|
||||||
|
export type UndoAction =
|
||||||
|
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||||
|
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
|
||||||
|
| { type: "delete"; feature: Feature }
|
||||||
|
| { type: "create"; id: FeatureProperties["id"] };
|
||||||
|
|
||||||
31
src/uhm/lib/editor/draft/useDraftState.ts
Normal file
31
src/uhm/lib/editor/draft/useDraftState.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
|
||||||
|
|
||||||
|
export function useDraftState(initialData: FeatureCollection) {
|
||||||
|
// Draft hiện tại (React state) để UI re-render khi dữ liệu thay đổi.
|
||||||
|
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||||
|
// Draft ref để đọc giá trị mới nhất trong event handlers/engines mà không cần deps.
|
||||||
|
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
|
||||||
|
|
||||||
|
const commitDraft = useCallback((nextDraft: FeatureCollection) => {
|
||||||
|
const cloned = deepClone(nextDraft);
|
||||||
|
draftRef.current = cloned;
|
||||||
|
setDraft(cloned);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftRef.current = draft;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
const resetDraft = useCallback((nextDraft: FeatureCollection) => {
|
||||||
|
commitDraft(nextDraft);
|
||||||
|
}, [commitDraft]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
draft,
|
||||||
|
draftRef,
|
||||||
|
commitDraft,
|
||||||
|
resetDraft,
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/uhm/lib/editor/draft/useUndoStack.ts
Normal file
81
src/uhm/lib/editor/draft/useUndoStack.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
import { geometryEquals } from "@/uhm/lib/editor/draft/draftDiff";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
applyUndoAction: (action: UndoAction) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useUndoStack(options: Options) {
|
||||||
|
const { applyUndoAction } = options;
|
||||||
|
// Stack thao tác undo (append-only, pop khi undo).
|
||||||
|
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||||
|
|
||||||
|
const pushUndo = useCallback((action: UndoAction) => {
|
||||||
|
setUndoStack((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (isSameUndo(last, action)) return prev;
|
||||||
|
return [...prev, action];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
let applied = false;
|
||||||
|
setUndoStack((prev) => {
|
||||||
|
if (applied) return prev;
|
||||||
|
if (!prev.length) return prev;
|
||||||
|
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
const remaining = prev.slice(0, -1);
|
||||||
|
applied = true;
|
||||||
|
|
||||||
|
const didApply = applyUndoAction(last);
|
||||||
|
return didApply ? remaining : prev;
|
||||||
|
});
|
||||||
|
}, [applyUndoAction]);
|
||||||
|
|
||||||
|
const clearUndo = useCallback(() => {
|
||||||
|
setUndoStack([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
undoStack,
|
||||||
|
pushUndo,
|
||||||
|
undo,
|
||||||
|
clearUndo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||||
|
if (!a) return false;
|
||||||
|
if (a.type !== b.type) return false;
|
||||||
|
switch (a.type) {
|
||||||
|
case "create": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "create" }>;
|
||||||
|
return a.id === next.id;
|
||||||
|
}
|
||||||
|
case "delete": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "delete" }>;
|
||||||
|
return (
|
||||||
|
a.feature.properties.id === next.feature.properties.id &&
|
||||||
|
geometryEquals(a.feature.geometry, next.feature.geometry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "update": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "update" }>;
|
||||||
|
return (
|
||||||
|
a.id === next.id &&
|
||||||
|
geometryEquals(a.prevGeometry, next.prevGeometry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "properties": {
|
||||||
|
const next = b as Extract<UndoAction, { type: "properties" }>;
|
||||||
|
return (
|
||||||
|
a.id === next.id &&
|
||||||
|
JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/uhm/lib/editor/entity/entityBinding.ts
Normal file
109
src/uhm/lib/editor/entity/entityBinding.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
|
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||||
|
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
|
||||||
|
export function mergeEntitiesWithPending(
|
||||||
|
persistedEntities: Entity[],
|
||||||
|
pendingCreates: PendingEntityCreate[]
|
||||||
|
): Entity[] {
|
||||||
|
if (!pendingCreates.length) {
|
||||||
|
return persistedEntities;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const pendingAsEntities: Entity[] = [];
|
||||||
|
for (const pending of pendingCreates) {
|
||||||
|
if (seen.has(pending.id)) continue;
|
||||||
|
seen.add(pending.id);
|
||||||
|
pendingAsEntities.push({
|
||||||
|
id: pending.id,
|
||||||
|
name: pending.name,
|
||||||
|
slug: pending.slug,
|
||||||
|
type_id: pending.type_id,
|
||||||
|
status: pending.status,
|
||||||
|
geometry_count: 0,
|
||||||
|
created_at: undefined,
|
||||||
|
updated_at: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id));
|
||||||
|
return [...pendingAsEntities, ...nextPersisted];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeEntitySearchResults(
|
||||||
|
remoteRows: Entity[],
|
||||||
|
localRows: Entity[]
|
||||||
|
): Entity[] {
|
||||||
|
const merged: Entity[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of localRows) {
|
||||||
|
if (!row.id || seen.has(row.id)) continue;
|
||||||
|
seen.add(row.id);
|
||||||
|
merged.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of remoteRows) {
|
||||||
|
if (!row.id || seen.has(row.id)) continue;
|
||||||
|
seen.add(row.id);
|
||||||
|
merged.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
|
||||||
|
const entityIds = normalizeFeatureEntityIds(feature);
|
||||||
|
if (!entityIds.length) return "Chưa gắn";
|
||||||
|
|
||||||
|
const names = entityIds
|
||||||
|
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
|
||||||
|
.filter((name) => name.trim().length > 0);
|
||||||
|
return names.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClientEntityId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `entity-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFeatureEntityPatch(
|
||||||
|
feature: Feature,
|
||||||
|
entityIds: string[],
|
||||||
|
entities: Entity[]
|
||||||
|
): Partial<FeatureProperties> {
|
||||||
|
const primaryEntityId = entityIds[0] || null;
|
||||||
|
const primaryEntity = primaryEntityId
|
||||||
|
? entities.find((entity) => entity.id === primaryEntityId) || null
|
||||||
|
: null;
|
||||||
|
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entities) ||
|
||||||
|
feature.properties.type ||
|
||||||
|
null;
|
||||||
|
const entityNames = entityIds
|
||||||
|
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
|
||||||
|
.filter((name) => name.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: nextGeometryType,
|
||||||
|
entity_id: primaryEntityId,
|
||||||
|
entity_ids: entityIds,
|
||||||
|
entity_name: primaryEntity?.name || null,
|
||||||
|
entity_names: entityNames,
|
||||||
|
entity_type_id: primaryEntity?.type_id || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGeometryTypeFromEntityIds(
|
||||||
|
entityIds: string[],
|
||||||
|
entities: Entity[]
|
||||||
|
): string | null {
|
||||||
|
const primaryEntityId = entityIds[0] || null;
|
||||||
|
if (!primaryEntityId) return null;
|
||||||
|
const primaryEntity = entities.find((entity) => entity.id === primaryEntityId) || null;
|
||||||
|
return primaryEntity?.type_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
50
src/uhm/lib/editor/geometry/geometryMetadata.ts
Normal file
50
src/uhm/lib/editor/geometry/geometryMetadata.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
|
||||||
|
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import {
|
||||||
|
normalizeFeatureBindingIds,
|
||||||
|
parseBindingInput,
|
||||||
|
} from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
|
||||||
|
export type GeometryMetadataPatch = {
|
||||||
|
patch: Partial<FeatureProperties>;
|
||||||
|
formState: GeometryMetaFormState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildGeometryMetadataPatch(form: GeometryMetaFormState): GeometryMetadataPatch {
|
||||||
|
const timeStart = parseOptionalYearInput(form.time_start, "time_start");
|
||||||
|
const timeEnd = parseOptionalYearInput(form.time_end, "time_end");
|
||||||
|
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||||
|
throw new Error("time_start phải <= time_end.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindingIds = parseBindingInput(form.binding);
|
||||||
|
return {
|
||||||
|
patch: {
|
||||||
|
time_start: timeStart,
|
||||||
|
time_end: timeEnd,
|
||||||
|
binding: bindingIds,
|
||||||
|
},
|
||||||
|
formState: {
|
||||||
|
time_start: timeStart != null ? String(timeStart) : "",
|
||||||
|
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||||
|
binding: bindingIds.join(", "),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBindingIdsForDisplay(feature: Feature): string {
|
||||||
|
const bindingIds = normalizeFeatureBindingIds(feature);
|
||||||
|
if (!bindingIds.length) return "Không có";
|
||||||
|
return bindingIds.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
||||||
|
const value = raw.trim();
|
||||||
|
if (!value.length) return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
throw new Error(`${fieldName} phải là số.`);
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
257
src/uhm/lib/editor/section/useSectionCommands.ts
Normal file
257
src/uhm/lib/editor/section/useSectionCommands.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import { ApiError } from "@/uhm/api/http";
|
||||||
|
import {
|
||||||
|
createSection,
|
||||||
|
createSectionCommit,
|
||||||
|
fetchSectionCommits,
|
||||||
|
fetchSections,
|
||||||
|
openSectionEditor,
|
||||||
|
restoreSectionCommit,
|
||||||
|
submitSection,
|
||||||
|
} from "@/uhm/api/sections";
|
||||||
|
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||||
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
import type { CreatedEntitySummary, PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
|
||||||
|
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
type EditorDraftApi = {
|
||||||
|
draft: FeatureCollection;
|
||||||
|
buildPayload: () => Change[];
|
||||||
|
clearChanges: () => void;
|
||||||
|
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
editor: EditorDraftApi;
|
||||||
|
editorUserId: string;
|
||||||
|
emptyFeatureCollection: FeatureCollection;
|
||||||
|
activeSection: Section | null;
|
||||||
|
sectionState: SectionState | null;
|
||||||
|
selectedSectionId: string;
|
||||||
|
newSectionTitle: string;
|
||||||
|
pendingSaveCount: number;
|
||||||
|
pendingEntityCreates: PendingEntityCreate[];
|
||||||
|
lastSectionSnapshot: EditorSnapshot | null;
|
||||||
|
commitTitle: string;
|
||||||
|
commitNote: string;
|
||||||
|
setActiveSection: Dispatch<SetStateAction<Section | null>>;
|
||||||
|
setSelectedSectionId: Dispatch<SetStateAction<string>>;
|
||||||
|
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
|
||||||
|
setLastSectionSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
|
||||||
|
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
|
||||||
|
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
|
||||||
|
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
|
||||||
|
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
|
||||||
|
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
|
||||||
|
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
|
setEntityStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
|
setIsSaving: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setAvailableSections: Dispatch<SetStateAction<Section[]>>;
|
||||||
|
setNewSectionTitle: Dispatch<SetStateAction<string>>;
|
||||||
|
setCommitTitle: Dispatch<SetStateAction<string>>;
|
||||||
|
setCommitNote: Dispatch<SetStateAction<string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSectionCommands(options: Options) {
|
||||||
|
const openSectionForEditing = useCallback(async (sectionId: string) => {
|
||||||
|
const editorPayload = await openSectionEditor(sectionId);
|
||||||
|
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||||
|
const commits = await fetchSectionCommits(sectionId);
|
||||||
|
const nextInitialData = snapshot?.editor_feature_collection || options.emptyFeatureCollection;
|
||||||
|
|
||||||
|
options.setActiveSection(editorPayload.section);
|
||||||
|
options.setSelectedSectionId(editorPayload.section.id);
|
||||||
|
options.setSectionState(editorPayload.state);
|
||||||
|
options.setLastSectionSnapshot(snapshot);
|
||||||
|
options.setInitialData(nextInitialData);
|
||||||
|
options.setSectionCommits(commits);
|
||||||
|
options.setPendingEntityCreates([]);
|
||||||
|
options.setCreatedEntities([]);
|
||||||
|
options.setSelectedFeatureId(null);
|
||||||
|
options.setEntityFormStatus(null);
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const commitSection = useCallback(async () => {
|
||||||
|
if (!options.activeSection || !options.sectionState) {
|
||||||
|
options.setEntityStatus("Chưa mở được section editor.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometryChanges = options.editor.buildPayload();
|
||||||
|
options.setIsSaving(true);
|
||||||
|
options.setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
const snapshot = buildEditorSnapshot({
|
||||||
|
section: options.activeSection,
|
||||||
|
draft: options.editor.draft,
|
||||||
|
changes: geometryChanges,
|
||||||
|
pendingEntities: options.pendingEntityCreates,
|
||||||
|
previousSnapshot: options.lastSectionSnapshot,
|
||||||
|
hasPersistedFeature: options.editor.hasPersistedFeature,
|
||||||
|
});
|
||||||
|
const result = await createSectionCommit(options.activeSection.id, {
|
||||||
|
snapshot,
|
||||||
|
edit_summary: options.commitTitle.trim()
|
||||||
|
|| options.commitNote.trim()
|
||||||
|
|| `Edit ${new Date().toLocaleString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
options.setSectionState(result.state);
|
||||||
|
options.setLastSectionSnapshot(snapshot);
|
||||||
|
options.setInitialData(options.editor.draft);
|
||||||
|
options.editor.clearChanges();
|
||||||
|
options.setPendingEntityCreates([]);
|
||||||
|
options.setCreatedEntities([]);
|
||||||
|
options.setCommitTitle("");
|
||||||
|
options.setCommitNote("");
|
||||||
|
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||||
|
options.setEntityFormStatus("Đã tạo commit.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
console.error("Commit failed", err.body);
|
||||||
|
options.setEntityStatus(`Commit thất bại: ${err.body}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Commit error", err);
|
||||||
|
options.setEntityStatus("Commit thất bại.");
|
||||||
|
} finally {
|
||||||
|
options.setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const openSelectedSection = useCallback(async () => {
|
||||||
|
const sectionId = options.selectedSectionId.trim();
|
||||||
|
if (!sectionId) {
|
||||||
|
options.setEntityStatus("Hãy chọn section để mở.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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?");
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.setIsOpeningSection(true);
|
||||||
|
options.setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
await openSectionForEditing(sectionId);
|
||||||
|
options.setEntityStatus("Đã mở section để chỉnh sửa.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
options.setEntityStatus(`Mở section thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
options.setEntityStatus("Mở section thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
options.setIsOpeningSection(false);
|
||||||
|
}
|
||||||
|
}, [openSectionForEditing, options]);
|
||||||
|
|
||||||
|
const createAndOpenSection = useCallback(async () => {
|
||||||
|
const title = options.newSectionTitle.trim();
|
||||||
|
if (!title) {
|
||||||
|
options.setEntityStatus("Tên section là bắt buộc.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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?");
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.setIsOpeningSection(true);
|
||||||
|
options.setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
const section = await createSection({
|
||||||
|
title,
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
const sections = await fetchSections();
|
||||||
|
options.setAvailableSections(sections);
|
||||||
|
options.setNewSectionTitle("");
|
||||||
|
await openSectionForEditing(section.id);
|
||||||
|
options.setEntityStatus("Đã tạo và mở section mới.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
options.setEntityStatus(`Tạo section thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
options.setEntityStatus("Tạo section thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
options.setIsOpeningSection(false);
|
||||||
|
}
|
||||||
|
}, [openSectionForEditing, options]);
|
||||||
|
|
||||||
|
const submitCurrentSection = useCallback(async () => {
|
||||||
|
if (!options.activeSection || !options.sectionState?.head_commit_id) {
|
||||||
|
options.setEntityStatus("Section hiện tại chưa có head để submit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.pendingSaveCount > 0) {
|
||||||
|
options.setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.setIsSubmitting(true);
|
||||||
|
options.setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
const submission = await submitSection(options.activeSection.id);
|
||||||
|
options.setEntityStatus(`Đã submit, submission ${submission.id}.`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
options.setEntityStatus(`Submit thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
options.setEntityStatus("Submit thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
options.setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const restoreCommit = useCallback(async (commitId: string) => {
|
||||||
|
if (!options.activeSection || !options.sectionState) {
|
||||||
|
options.setEntityStatus("Chưa mở được section editor.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.pendingSaveCount > 0) {
|
||||||
|
options.setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.setIsSaving(true);
|
||||||
|
options.setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
const result = await restoreSectionCommit(options.activeSection.id, {
|
||||||
|
commit_id: commitId,
|
||||||
|
});
|
||||||
|
const editorPayload = await openSectionEditor(options.activeSection.id);
|
||||||
|
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
|
||||||
|
options.setSectionState(result.state);
|
||||||
|
options.setLastSectionSnapshot(snapshot);
|
||||||
|
if (snapshot?.editor_feature_collection) {
|
||||||
|
options.setInitialData(snapshot.editor_feature_collection);
|
||||||
|
}
|
||||||
|
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
|
||||||
|
options.setEntityFormStatus("Đã restore commit.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
options.setEntityStatus(`Restore thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
options.setEntityStatus("Restore thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
options.setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
openSectionForEditing,
|
||||||
|
commitSection,
|
||||||
|
openSelectedSection,
|
||||||
|
createAndOpenSection,
|
||||||
|
submitCurrentSection,
|
||||||
|
restoreCommit,
|
||||||
|
};
|
||||||
|
}
|
||||||
44
src/uhm/lib/editor/session/sessionTypes.ts
Normal file
44
src/uhm/lib/editor/session/sessionTypes.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { EntityGeometryPreset } from "@/uhm/lib/entityTypeOptions";
|
||||||
|
|
||||||
|
export type EditorMode =
|
||||||
|
| "idle"
|
||||||
|
| "draw"
|
||||||
|
| "select"
|
||||||
|
| "add-point"
|
||||||
|
| "add-line"
|
||||||
|
| "add-path"
|
||||||
|
| "add-circle";
|
||||||
|
|
||||||
|
export type TimelineRange = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityFormState = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
type_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometryMetaFormState = {
|
||||||
|
time_start: string;
|
||||||
|
time_end: string;
|
||||||
|
binding: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PendingEntityCreate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string | null;
|
||||||
|
type_id: string;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatedEntitySummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometryPreset = EntityGeometryPreset;
|
||||||
|
|
||||||
21
src/uhm/lib/editor/session/useBackgroundSessionState.ts
Normal file
21
src/uhm/lib/editor/session/useBackgroundSessionState.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
BackgroundLayerVisibility,
|
||||||
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
} from "@/uhm/lib/backgroundLayers";
|
||||||
|
|
||||||
|
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).
|
||||||
|
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||||
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||||
|
);
|
||||||
|
// Đảm bảo đã load visibility trước khi render map thật.
|
||||||
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundVisibility,
|
||||||
|
setBackgroundVisibility,
|
||||||
|
isBackgroundVisibilityReady,
|
||||||
|
setIsBackgroundVisibilityReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
80
src/uhm/lib/editor/session/useEntitySessionState.ts
Normal file
80
src/uhm/lib/editor/session/useEntitySessionState.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { Entity } from "@/uhm/types/entities";
|
||||||
|
import type { FeatureId } from "@/uhm/types/geo";
|
||||||
|
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||||
|
import type {
|
||||||
|
CreatedEntitySummary,
|
||||||
|
EntityFormState,
|
||||||
|
GeometryMetaFormState,
|
||||||
|
PendingEntityCreate,
|
||||||
|
} from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
export function useEntitySessionState() {
|
||||||
|
// Entities đã persisted từ backend (dùng cho search/binding).
|
||||||
|
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
||||||
|
// Entities tạo mới trong phiên nhưng chưa commit lên backend.
|
||||||
|
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||||
|
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
|
||||||
|
const [createdEntities, setCreatedEntities] = useState<CreatedEntitySummary[]>([]);
|
||||||
|
// Thông báo trạng thái/lỗi liên quan entity/session.
|
||||||
|
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||||
|
// Feature đang được chọn để thao tác bind entities/metadata.
|
||||||
|
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
|
||||||
|
// Form tạo entity mới (độc lập).
|
||||||
|
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||||
|
});
|
||||||
|
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
|
||||||
|
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||||
|
// Form metadata geometry (time range + binding ids).
|
||||||
|
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||||
|
time_start: "",
|
||||||
|
time_end: "",
|
||||||
|
binding: "",
|
||||||
|
});
|
||||||
|
// Cờ loading khi apply entity/metadata (local submit).
|
||||||
|
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
||||||
|
// Thông báo trạng thái/lỗi cho form entity/metadata.
|
||||||
|
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
||||||
|
// Keyword search entity theo name.
|
||||||
|
const [entitySearchQuery, setEntitySearchQuery] = useState("");
|
||||||
|
// Kết quả search entity để user chọn.
|
||||||
|
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||||
|
// Entity ID đang được chọn trong dropdown kết quả search.
|
||||||
|
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||||
|
// Cờ loading khi search entity.
|
||||||
|
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
persistedEntities,
|
||||||
|
setPersistedEntities,
|
||||||
|
pendingEntityCreates,
|
||||||
|
setPendingEntityCreates,
|
||||||
|
createdEntities,
|
||||||
|
setCreatedEntities,
|
||||||
|
entityStatus,
|
||||||
|
setEntityStatus,
|
||||||
|
selectedFeatureId,
|
||||||
|
setSelectedFeatureId,
|
||||||
|
entityForm,
|
||||||
|
setEntityForm,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
geometryMetaForm,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
isEntitySubmitting,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
entityFormStatus,
|
||||||
|
setEntityFormStatus,
|
||||||
|
entitySearchQuery,
|
||||||
|
setEntitySearchQuery,
|
||||||
|
entitySearchResults,
|
||||||
|
setEntitySearchResults,
|
||||||
|
selectedSearchEntityId,
|
||||||
|
setSelectedSearchEntityId,
|
||||||
|
isEntitySearchLoading,
|
||||||
|
setIsEntitySearchLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
85
src/uhm/lib/editor/session/useSectionSessionState.ts
Normal file
85
src/uhm/lib/editor/session/useSectionSessionState.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
defaultEditorUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SectionTask = "idle" | "saving" | "submitting" | "opening-section";
|
||||||
|
|
||||||
|
export function useSectionSessionState(options: Options) {
|
||||||
|
// Single state machine cho các tác vụ async của section (saving/submitting/opening).
|
||||||
|
const [sectionTask, setSectionTask] = useState<SectionTask>("idle");
|
||||||
|
const setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
|
||||||
|
setSectionTask((prev) => {
|
||||||
|
const currentValue = prev === task;
|
||||||
|
const nextValue = typeof next === "function" ? next(currentValue) : next;
|
||||||
|
if (nextValue) return task;
|
||||||
|
return prev === task ? "idle" : prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isSaving = sectionTask === "saving";
|
||||||
|
const isSubmitting = sectionTask === "submitting";
|
||||||
|
const isOpeningSection = sectionTask === "opening-section";
|
||||||
|
const setIsSaving: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||||
|
setTaskFlag("saving", next);
|
||||||
|
}, [setTaskFlag]);
|
||||||
|
const setIsSubmitting: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||||
|
setTaskFlag("submitting", next);
|
||||||
|
}, [setTaskFlag]);
|
||||||
|
const setIsOpeningSection: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
|
||||||
|
setTaskFlag("opening-section", next);
|
||||||
|
}, [setTaskFlag]);
|
||||||
|
|
||||||
|
// Danh sách sections để user chọn mở.
|
||||||
|
const [availableSections, setAvailableSections] = useState<Section[]>([]);
|
||||||
|
// Section ID đang được chọn trong dropdown.
|
||||||
|
const [selectedSectionId, setSelectedSectionId] = useState("");
|
||||||
|
// Title section mới (để create).
|
||||||
|
const [newSectionTitle, setNewSectionTitle] = useState("");
|
||||||
|
// Input title cho commit.
|
||||||
|
const [commitTitle, setCommitTitle] = useState("");
|
||||||
|
// Input note cho commit.
|
||||||
|
const [commitNote, setCommitNote] = useState("");
|
||||||
|
// User ID dùng để gắn vào commit/submit/lock.
|
||||||
|
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
|
||||||
|
// Section đang mở để edit (null nếu chưa mở).
|
||||||
|
const [activeSection, setActiveSection] = useState<Section | null>(null);
|
||||||
|
// Trạng thái section (version/head/status/lock).
|
||||||
|
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
||||||
|
// Danh sách commits của section đang mở.
|
||||||
|
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
||||||
|
// Snapshot gần nhất đã load (để build snapshot diff/metadata).
|
||||||
|
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSaving,
|
||||||
|
setIsSaving,
|
||||||
|
isSubmitting,
|
||||||
|
setIsSubmitting,
|
||||||
|
isOpeningSection,
|
||||||
|
setIsOpeningSection,
|
||||||
|
availableSections,
|
||||||
|
setAvailableSections,
|
||||||
|
selectedSectionId,
|
||||||
|
setSelectedSectionId,
|
||||||
|
newSectionTitle,
|
||||||
|
setNewSectionTitle,
|
||||||
|
commitTitle,
|
||||||
|
setCommitTitle,
|
||||||
|
commitNote,
|
||||||
|
setCommitNote,
|
||||||
|
editorUserIdInput,
|
||||||
|
setEditorUserIdInput,
|
||||||
|
activeSection,
|
||||||
|
setActiveSection,
|
||||||
|
sectionState,
|
||||||
|
setSectionState,
|
||||||
|
sectionCommits,
|
||||||
|
setSectionCommits,
|
||||||
|
lastSectionSnapshot,
|
||||||
|
setLastSectionSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/uhm/lib/editor/session/useTimelineState.ts
Normal file
42
src/uhm/lib/editor/session/useTimelineState.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import { clampYearValue } from "@/uhm/lib/timeline";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
currentYear: number;
|
||||||
|
fallbackTimelineRange: TimelineRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTimelineState(options: Options) {
|
||||||
|
// Năm timeline "đã chốt" để fetch dữ liệu.
|
||||||
|
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
||||||
|
clampYearValue(
|
||||||
|
options.currentYear,
|
||||||
|
options.fallbackTimelineRange.min,
|
||||||
|
options.fallbackTimelineRange.max
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Năm timeline đang chỉnh (debounce rồi đẩy sang timelineYear).
|
||||||
|
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
||||||
|
clampYearValue(
|
||||||
|
options.currentYear,
|
||||||
|
options.fallbackTimelineRange.min,
|
||||||
|
options.fallbackTimelineRange.max
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Cờ loading khi fetch theo timeline.
|
||||||
|
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||||
|
// Thông báo trạng thái/lỗi khi fetch theo timeline.
|
||||||
|
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timelineYear,
|
||||||
|
setTimelineYear,
|
||||||
|
timelineDraftYear,
|
||||||
|
setTimelineDraftYear,
|
||||||
|
isTimelineLoading,
|
||||||
|
setIsTimelineLoading,
|
||||||
|
timelineStatus,
|
||||||
|
setTimelineStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
254
src/uhm/lib/editor/snapshot/editorSnapshot.ts
Normal file
254
src/uhm/lib/editor/snapshot/editorSnapshot.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||||
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
|
||||||
|
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
||||||
|
|
||||||
|
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||||
|
const snapshot = raw as EditorSnapshot;
|
||||||
|
if (
|
||||||
|
snapshot.editor_feature_collection &&
|
||||||
|
snapshot.editor_feature_collection.type === "FeatureCollection" &&
|
||||||
|
Array.isArray(snapshot.editor_feature_collection.features)
|
||||||
|
) {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
editor_feature_collection: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEditorSnapshot(options: {
|
||||||
|
section: Section;
|
||||||
|
draft: FeatureCollection;
|
||||||
|
changes: Change[];
|
||||||
|
pendingEntities: PendingEntityCreate[];
|
||||||
|
previousSnapshot: EditorSnapshot | null;
|
||||||
|
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
|
||||||
|
}): EditorSnapshot {
|
||||||
|
const changedIds = new Set(options.changes.map((change) =>
|
||||||
|
String(change.action === "create" ? change.feature.properties.id : change.id)
|
||||||
|
));
|
||||||
|
const deletedIds = new Set(
|
||||||
|
options.changes
|
||||||
|
.filter((change): change is Extract<Change, { action: "delete" }> => change.action === "delete")
|
||||||
|
.map((change) => String(change.id))
|
||||||
|
);
|
||||||
|
const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id)));
|
||||||
|
const previousFeatures = new globalThis.Map<string, Feature>();
|
||||||
|
for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) {
|
||||||
|
previousFeatures.set(String(feature.properties.id), feature);
|
||||||
|
if (!currentDraftIds.has(String(feature.properties.id))) {
|
||||||
|
deletedIds.add(String(feature.properties.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousGeometryOps = new globalThis.Map<string, GeometrySnapshot["operation"]>();
|
||||||
|
for (const item of options.previousSnapshot?.geometries || []) {
|
||||||
|
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
|
||||||
|
const operation = item.operation;
|
||||||
|
if (id && operation) previousGeometryOps.set(id, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
|
||||||
|
const entityRows = new globalThis.Map<string, EntitySnapshot>();
|
||||||
|
for (const item of options.previousSnapshot?.entities || []) {
|
||||||
|
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
|
||||||
|
if (id) entityRows.set(id, { ...item });
|
||||||
|
}
|
||||||
|
for (const entity of options.pendingEntities) {
|
||||||
|
entityRows.set(entity.id, {
|
||||||
|
id: entity.id,
|
||||||
|
operation: "create",
|
||||||
|
name: entity.name,
|
||||||
|
slug: entity.slug,
|
||||||
|
description: null,
|
||||||
|
type_id: entity.type_id,
|
||||||
|
status: entity.status,
|
||||||
|
is_deleted: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feature of options.draft.features) {
|
||||||
|
for (const entityId of normalizeFeatureEntityIds(feature)) {
|
||||||
|
if (entityRows.has(entityId)) continue;
|
||||||
|
entityRows.set(entityId, {
|
||||||
|
id: entityId,
|
||||||
|
operation: "reference",
|
||||||
|
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
|
||||||
|
slug: null,
|
||||||
|
description: null,
|
||||||
|
type_id: feature.properties.entity_type_id || feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
|
||||||
|
status: 1,
|
||||||
|
is_deleted: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometries: GeometrySnapshot[] = options.draft.features.map((feature) => {
|
||||||
|
const id = String(feature.properties.id);
|
||||||
|
const previousOperation = previousGeometryOps.get(id);
|
||||||
|
const previousFeature = previousFeatures.get(id);
|
||||||
|
const changedFromPreviousSnapshot = previousFeature
|
||||||
|
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
|
||||||
|
: false;
|
||||||
|
const operation: GeometrySnapshot["operation"] = previousOperation === "create"
|
||||||
|
? "create"
|
||||||
|
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
|
||||||
|
? "create"
|
||||||
|
: changedIds.has(id) || changedFromPreviousSnapshot
|
||||||
|
? "update"
|
||||||
|
: "reference";
|
||||||
|
const bbox = getFeatureBBox(feature);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
operation,
|
||||||
|
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
|
||||||
|
draw_geometry: feature.geometry,
|
||||||
|
binding: normalizeFeatureBindingIds(feature),
|
||||||
|
time_start: feature.properties.time_start ?? null,
|
||||||
|
time_end: feature.properties.time_end ?? null,
|
||||||
|
bbox: bbox
|
||||||
|
? {
|
||||||
|
min_lng: bbox.minLng,
|
||||||
|
min_lat: bbox.minLat,
|
||||||
|
max_lng: bbox.maxLng,
|
||||||
|
max_lat: bbox.maxLat,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
is_deleted: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const id of deletedIds) {
|
||||||
|
geometries.push({
|
||||||
|
id,
|
||||||
|
operation: "delete",
|
||||||
|
is_deleted: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkScopes: LinkScopeSnapshot[] = options.draft.features
|
||||||
|
.map((feature) => ({
|
||||||
|
geometry_id: String(feature.properties.id),
|
||||||
|
operation: "replace" as const,
|
||||||
|
entity_ids: normalizeFeatureEntityIds(feature),
|
||||||
|
}))
|
||||||
|
.filter((scope) => scope.entity_ids.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema_version: 1,
|
||||||
|
section: {
|
||||||
|
id: options.section.id,
|
||||||
|
title: options.section.title,
|
||||||
|
},
|
||||||
|
editor_feature_collection: JSON.parse(JSON.stringify(options.draft)) as FeatureCollection,
|
||||||
|
entities: Array.from(entityRows.values()).map((entity) => {
|
||||||
|
const id = String(entity.id || "");
|
||||||
|
if (pendingEntityIds.has(id)) return entity;
|
||||||
|
return entity;
|
||||||
|
}),
|
||||||
|
geometries,
|
||||||
|
link_scopes: linkScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultTypeIdForFeature(feature: Feature): string {
|
||||||
|
const preset = feature.properties.geometry_preset;
|
||||||
|
if (preset === "line") return "defense_line";
|
||||||
|
if (preset === "point") return "city";
|
||||||
|
if (preset === "circle-area") return "war";
|
||||||
|
if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID;
|
||||||
|
|
||||||
|
const geometryType = feature.geometry.type;
|
||||||
|
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||||
|
return "defense_line";
|
||||||
|
}
|
||||||
|
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||||
|
return "city";
|
||||||
|
}
|
||||||
|
return DEFAULT_ENTITY_TYPE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFeatureEntityIds(feature: Feature): string[] {
|
||||||
|
const fromArray = Array.isArray(feature.properties.entity_ids)
|
||||||
|
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (fromArray.length) {
|
||||||
|
return uniqueEntityIds(fromArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
const single = feature.properties.entity_id;
|
||||||
|
if (typeof single === "string" && single.trim().length > 0) {
|
||||||
|
return [single.trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFeatureBindingIds(feature: Feature): string[] {
|
||||||
|
const rawBinding = feature.properties.binding;
|
||||||
|
if (!Array.isArray(rawBinding)) return [];
|
||||||
|
return uniqueEntityIds(rawBinding
|
||||||
|
.map((id) => {
|
||||||
|
if (typeof id !== "string" && typeof id !== "number") return "";
|
||||||
|
return String(id).trim();
|
||||||
|
})
|
||||||
|
.filter((id) => id.length > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBindingInput(raw: string): string[] {
|
||||||
|
if (!raw.trim().length) return [];
|
||||||
|
return uniqueEntityIds(
|
||||||
|
raw
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueEntityIds(ids: string[]): string[] {
|
||||||
|
const deduped: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const rawId of ids) {
|
||||||
|
const id = rawId.trim();
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
deduped.push(id);
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
|
||||||
|
const points = 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
247
src/uhm/lib/engine/circleEngine.ts
Normal file
247
src/uhm/lib/engine/circleEngine.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
const EARTH_RADIUS_METERS = 6371008.8;
|
||||||
|
const CIRCLE_SEGMENTS = 72;
|
||||||
|
const MIN_RADIUS_METERS = 1;
|
||||||
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ circle bằng thao tác kéo chuột từ tâm ra biên.
|
||||||
|
export function initCircle(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
let center: [number, number] | null = null;
|
||||||
|
let radiusMeters = 0;
|
||||||
|
let isDragging = false;
|
||||||
|
let dragPanDisabledByCircle = false;
|
||||||
|
|
||||||
|
// Xóa dữ liệu preview circle trên map.
|
||||||
|
const clearPreview = () => {
|
||||||
|
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||||
|
EMPTY_PREVIEW
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bật lại drag pan nếu trước đó bị tắt khi đang kéo vẽ circle.
|
||||||
|
const releaseDragPan = () => {
|
||||||
|
if (!dragPanDisabledByCircle) return;
|
||||||
|
dragPanDisabledByCircle = false;
|
||||||
|
if (!map.dragPan.isEnabled()) {
|
||||||
|
map.dragPan.enable();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset toàn bộ trạng thái vẽ circle tạm thời.
|
||||||
|
const resetDrawingState = () => {
|
||||||
|
center = null;
|
||||||
|
radiusMeters = 0;
|
||||||
|
isDragging = false;
|
||||||
|
clearPreview();
|
||||||
|
releaseDragPan();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật polygon preview theo tâm và bán kính hiện tại.
|
||||||
|
const updatePreview = () => {
|
||||||
|
if (!center || radiusMeters < MIN_RADIUS_METERS) {
|
||||||
|
clearPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
||||||
|
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [ring],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bắt đầu phiên vẽ circle khi nhấn chuột trái.
|
||||||
|
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
if (getMode() !== "add-circle") return;
|
||||||
|
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||||
|
|
||||||
|
center = [e.lngLat.lng, e.lngLat.lat];
|
||||||
|
radiusMeters = 0;
|
||||||
|
isDragging = true;
|
||||||
|
clearPreview();
|
||||||
|
|
||||||
|
if (map.dragPan.isEnabled()) {
|
||||||
|
map.dragPan.disable();
|
||||||
|
dragPanDisabledByCircle = true;
|
||||||
|
} else {
|
||||||
|
dragPanDisabledByCircle = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật bán kính theo vị trí chuột trong lúc kéo.
|
||||||
|
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
if (getMode() !== "add-circle") {
|
||||||
|
if (canvas.style.cursor === "crosshair") {
|
||||||
|
canvas.style.cursor = "";
|
||||||
|
}
|
||||||
|
if (isDragging) {
|
||||||
|
resetDrawingState();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.cursor = "crosshair";
|
||||||
|
if (!isDragging || !center) return;
|
||||||
|
|
||||||
|
radiusMeters = distanceMeters(center, [e.lngLat.lng, e.lngLat.lat]);
|
||||||
|
updatePreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hoàn tất circle và trả geometry cho callback.
|
||||||
|
const finishCircle = () => {
|
||||||
|
if (!isDragging || !center) {
|
||||||
|
resetDrawingState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (radiusMeters < MIN_RADIUS_METERS) {
|
||||||
|
resetDrawingState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
||||||
|
onComplete({
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [ring],
|
||||||
|
});
|
||||||
|
resetDrawingState();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kết thúc thao tác kéo bằng mouseup chuột trái.
|
||||||
|
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
if (getMode() !== "add-circle") return;
|
||||||
|
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||||
|
finishCircle();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hủy phiên vẽ circle khi nhấn Escape.
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (getMode() !== "add-circle") return;
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
e.preventDefault();
|
||||||
|
resetDrawingState();
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("mousedown", onMouseDown);
|
||||||
|
map.on("mousemove", onMouseMove);
|
||||||
|
map.on("mouseup", onMouseUp);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
map.off("mousedown", onMouseDown);
|
||||||
|
map.off("mousemove", onMouseMove);
|
||||||
|
map.off("mouseup", onMouseUp);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
resetDrawingState();
|
||||||
|
if (map.getCanvas().style.cursor === "crosshair") {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
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 độ.
|
||||||
|
}
|
||||||
127
src/uhm/lib/engine/drawingEngine.ts
Normal file
127
src/uhm/lib/engine/drawingEngine.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||||
|
export function initDrawing(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
|
const clearPreview = () => {
|
||||||
|
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDrawing = () => {
|
||||||
|
coords = [];
|
||||||
|
clearPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
|
||||||
|
function closePolygon(c: [number, number][]) {
|
||||||
|
if (c.length < 3) return c;
|
||||||
|
const first = c[0];
|
||||||
|
const last = c[c.length - 1];
|
||||||
|
|
||||||
|
if (first[0] !== last[0] || first[1] !== last[1]) {
|
||||||
|
return [...c, first];
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cập nhật layer preview trong lúc đang vẽ.
|
||||||
|
function update(c: [number, number][]) {
|
||||||
|
const closed = closePolygon(c);
|
||||||
|
|
||||||
|
(map.getSource("draw-preview") as maplibregl.GeoJSONSource)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [closed],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ghi nhận đỉnh polygon mới khi click map.
|
||||||
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "draw") return;
|
||||||
|
|
||||||
|
coords.push([e.lngLat.lng, e.lngLat.lat] as [number, number]);
|
||||||
|
update(coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render preview polygon với điểm chuột hiện tại.
|
||||||
|
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "draw" || coords.length === 0) return;
|
||||||
|
|
||||||
|
const preview: [number, number][] = [
|
||||||
|
...coords,
|
||||||
|
[e.lngLat.lng, e.lngLat.lat] as [number, number],
|
||||||
|
];
|
||||||
|
update(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hoàn tất polygon, trả geometry ra ngoài và reset preview.
|
||||||
|
function finishDrawing() {
|
||||||
|
if (getMode() !== "draw" || coords.length < 3) return;
|
||||||
|
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [closePolygon(coords)],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete(geometry);
|
||||||
|
cancelDrawing();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lắng nghe Enter để chốt polygon.
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (getMode() !== "draw") return;
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
finishDrawing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelDrawing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault();
|
||||||
|
coords = coords.slice(0, -1);
|
||||||
|
if (coords.length) {
|
||||||
|
update(coords);
|
||||||
|
} else {
|
||||||
|
clearPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
cancelDrawing();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
cancel: cancelDrawing,
|
||||||
|
};
|
||||||
|
}
|
||||||
226
src/uhm/lib/engine/editingEngine.ts
Normal file
226
src/uhm/lib/engine/editingEngine.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
|
||||||
|
export type EditingHandle = {
|
||||||
|
id: string | number;
|
||||||
|
ring: [number, number][];
|
||||||
|
original: Geometry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditingAPI = {
|
||||||
|
beginEditing: (feature: maplibregl.MapGeoJSONFeature) => void;
|
||||||
|
clearEditing: () => void;
|
||||||
|
bindEditEvents: (map: maplibregl.Map) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tạo engine chỉnh sửa polygon đã có (kéo đỉnh, thêm đỉnh, commit/cancel).
|
||||||
|
export function createEditingEngine(options: {
|
||||||
|
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||||
|
onUpdate: (id: string | number, geometry: Geometry) => void;
|
||||||
|
}) {
|
||||||
|
const { mapRef, onUpdate } = options;
|
||||||
|
const editingRef = { current: null as EditingHandle | null };
|
||||||
|
const dragStateRef = { current: null as { idx: number } | null };
|
||||||
|
const modifierRef = { current: { ctrl: false, meta: false } };
|
||||||
|
|
||||||
|
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
|
||||||
|
const clearEditing = () => {
|
||||||
|
editingRef.current = null;
|
||||||
|
dragStateRef.current = null;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const empty: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||||
|
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||||
|
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Đồng bộ polygon tạm và các handle point lên map source.
|
||||||
|
const updateEditSources = () => {
|
||||||
|
const editing = editingRef.current;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!editing || !map) return;
|
||||||
|
|
||||||
|
const closedRing = [...editing.ring, editing.ring[0]];
|
||||||
|
const shape: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
geometry: { type: "Polygon", coordinates: [closedRing] },
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const handles: GeoJSON.FeatureCollection<GeoJSON.Point> = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: editing.ring.map((c, idx) => ({
|
||||||
|
type: "Feature",
|
||||||
|
geometry: { type: "Point", coordinates: c },
|
||||||
|
properties: { idx },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
|
||||||
|
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chốt chỉnh sửa và emit geometry mới cho caller.
|
||||||
|
const finishEditing = () => {
|
||||||
|
const editing = editingRef.current;
|
||||||
|
if (!editing) return;
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [[...editing.ring, editing.ring[0]]],
|
||||||
|
};
|
||||||
|
onUpdate(editing.id, geometry);
|
||||||
|
clearEditing();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Thoát chế độ chỉnh sửa mà không lưu thay đổi.
|
||||||
|
const cancelEditing = () => {
|
||||||
|
clearEditing();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
|
||||||
|
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
|
||||||
|
if (feature.geometry.type !== "Polygon") return;
|
||||||
|
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
|
||||||
|
if (coords.length < 4) return;
|
||||||
|
|
||||||
|
// remove duplicated closing point
|
||||||
|
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
|
||||||
|
editingRef.current = {
|
||||||
|
id: feature.id ?? feature.properties?.id,
|
||||||
|
ring,
|
||||||
|
original: feature.geometry as Geometry,
|
||||||
|
};
|
||||||
|
updateEditSources();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh.
|
||||||
|
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
|
||||||
|
const oe = e?.originalEvent as MouseEvent | undefined;
|
||||||
|
return (
|
||||||
|
modifierRef.current.ctrl ||
|
||||||
|
modifierRef.current.meta ||
|
||||||
|
!!oe?.ctrlKey ||
|
||||||
|
!!oe?.metaKey
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
|
||||||
|
const bindEditEvents = (map: maplibregl.Map) => {
|
||||||
|
// Bắt đầu kéo một handle point.
|
||||||
|
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
if (!editingRef.current) return;
|
||||||
|
const feature = e.features?.[0];
|
||||||
|
const idx = feature?.properties?.idx;
|
||||||
|
if (idx === undefined) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragStateRef.current = { idx };
|
||||||
|
map.getCanvas().style.cursor = "grabbing";
|
||||||
|
map.dragPan.disable();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật vị trí đỉnh trong lúc kéo chuột.
|
||||||
|
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
const drag = dragStateRef.current;
|
||||||
|
const editing = editingRef.current;
|
||||||
|
if (!drag || !editing) return;
|
||||||
|
|
||||||
|
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
|
||||||
|
updateEditSources();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map.
|
||||||
|
const stopDragging = () => {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
map.dragPan.enable();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags).
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Control") {
|
||||||
|
modifierRef.current.ctrl = true;
|
||||||
|
} else if (e.key === "Meta") {
|
||||||
|
modifierRef.current.meta = true;
|
||||||
|
}
|
||||||
|
if (!editingRef.current) return;
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
finishEditing();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
cancelEditing();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hạ cờ modifier khi nhả phím.
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Control") {
|
||||||
|
modifierRef.current.ctrl = false;
|
||||||
|
} else if (e.key === "Meta") {
|
||||||
|
modifierRef.current.meta = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
if (!editingRef.current) return;
|
||||||
|
if (!isModifierPressed(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const editing = editingRef.current;
|
||||||
|
const ring = editing.ring;
|
||||||
|
const click = [e.lngLat.lng, e.lngLat.lat] as [number, number];
|
||||||
|
let nearestIdx = 0;
|
||||||
|
let bestDist = Number.POSITIVE_INFINITY;
|
||||||
|
ring.forEach((pt, idx) => {
|
||||||
|
const dx = pt[0] - click[0];
|
||||||
|
const dy = pt[1] - click[1];
|
||||||
|
const d = dx * dx + dy * dy; // Dùng khoảng cách Euclid bình phương để so sánh nhanh, không cần sqrt.
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
nearestIdx = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const insertIdx = nearestIdx + 1;
|
||||||
|
ring.splice(insertIdx, 0, click);
|
||||||
|
dragStateRef.current = { idx: insertIdx };
|
||||||
|
map.getCanvas().style.cursor = "grabbing";
|
||||||
|
map.dragPan.disable();
|
||||||
|
updateEditSources();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ngắt kéo nếu con trỏ rời canvas.
|
||||||
|
const onCanvasLeave = () => {
|
||||||
|
stopDragging();
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("mousedown", "edit-handles-circle", onHandleDown);
|
||||||
|
map.on("mousedown", "edit-shape-line", onInsertHandle);
|
||||||
|
map.on("mousemove", onHandleMove);
|
||||||
|
map.on("mouseup", stopDragging);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
document.addEventListener("keyup", onKeyUp);
|
||||||
|
map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
|
||||||
|
|
||||||
|
map.on("remove", () => {
|
||||||
|
map.off("mousedown", "edit-handles-circle", onHandleDown);
|
||||||
|
map.off("mousedown", "edit-shape-line", onInsertHandle);
|
||||||
|
map.off("mousemove", onHandleMove);
|
||||||
|
map.off("mouseup", stopDragging);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.removeEventListener("keyup", onKeyUp);
|
||||||
|
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
beginEditing,
|
||||||
|
clearEditing,
|
||||||
|
bindEditEvents,
|
||||||
|
updateEditSources,
|
||||||
|
editingRef,
|
||||||
|
dragStateRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
4
src/uhm/lib/engine/engineTypes.ts
Normal file
4
src/uhm/lib/engine/engineTypes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
export type ModeGetter = () => EditorMode;
|
||||||
|
|
||||||
140
src/uhm/lib/engine/lineEngine.ts
Normal file
140
src/uhm/lib/engine/lineEngine.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ line (gấp khúc, không mũi tên).
|
||||||
|
export function initLine(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
|
// Xóa dữ liệu preview line.
|
||||||
|
const clearPreview = () => {
|
||||||
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||||
|
EMPTY_PREVIEW
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hủy phiên vẽ line hiện tại.
|
||||||
|
const cancelLine = () => {
|
||||||
|
coords = [];
|
||||||
|
clearPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật line preview theo danh sách tọa độ tạm.
|
||||||
|
const updatePreview = (lineCoords: [number, number][]) => {
|
||||||
|
if (lineCoords.length < 2) {
|
||||||
|
clearPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: lineCoords,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chốt line khi đủ số đỉnh tối thiểu.
|
||||||
|
const finishLine = () => {
|
||||||
|
if (getMode() !== "add-line" || coords.length < 2) return;
|
||||||
|
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [...coords],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete(geometry);
|
||||||
|
cancelLine();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xóa đỉnh cuối cùng trong line đang vẽ.
|
||||||
|
const removeLastVertex = () => {
|
||||||
|
if (!coords.length) return;
|
||||||
|
coords = coords.slice(0, -1);
|
||||||
|
updatePreview(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Thêm một đỉnh line khi click map.
|
||||||
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
if (getMode() !== "add-line") return;
|
||||||
|
|
||||||
|
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||||
|
updatePreview(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật preview động theo vị trí chuột.
|
||||||
|
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
|
||||||
|
if (getMode() !== "add-line") {
|
||||||
|
if (coords.length) {
|
||||||
|
cancelLine();
|
||||||
|
}
|
||||||
|
if (canvas.style.cursor === "crosshair") {
|
||||||
|
canvas.style.cursor = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.cursor = "crosshair";
|
||||||
|
if (coords.length === 0) return;
|
||||||
|
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (getMode() !== "add-line") return;
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
finishLine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelLine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault();
|
||||||
|
removeLastVertex();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
cancelLine();
|
||||||
|
if (map.getCanvas().style.cursor === "crosshair") {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
cancel: cancelLine,
|
||||||
|
};
|
||||||
|
}
|
||||||
142
src/uhm/lib/engine/pathEngine.ts
Normal file
142
src/uhm/lib/engine/pathEngine.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ path (gấp khúc, sẽ render có mũi tên ở layer path).
|
||||||
|
export function initPath(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
|
// Xóa dữ liệu preview path.
|
||||||
|
const clearPreview = () => {
|
||||||
|
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||||
|
EMPTY_PREVIEW
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật path preview theo danh sách tọa độ tạm.
|
||||||
|
const updatePreview = (lineCoords: [number, number][]) => {
|
||||||
|
if (lineCoords.length < 2) {
|
||||||
|
clearPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: lineCoords,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chốt path khi đủ số đỉnh tối thiểu.
|
||||||
|
const finishPath = () => {
|
||||||
|
if (getMode() !== "add-path" || coords.length < 2) return;
|
||||||
|
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [...coords],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete(geometry);
|
||||||
|
coords = [];
|
||||||
|
clearPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hủy phiên vẽ path hiện tại.
|
||||||
|
const cancelPath = () => {
|
||||||
|
coords = [];
|
||||||
|
clearPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xóa đỉnh cuối cùng của path đang vẽ.
|
||||||
|
const removeLastVertex = () => {
|
||||||
|
if (coords.length === 0) return;
|
||||||
|
coords = coords.slice(0, -1);
|
||||||
|
updatePreview(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Thêm một đỉnh path khi click map.
|
||||||
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
if (getMode() !== "add-path") return;
|
||||||
|
|
||||||
|
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||||
|
updatePreview(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật preview path động theo vị trí chuột.
|
||||||
|
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
|
||||||
|
if (getMode() !== "add-path") {
|
||||||
|
if (coords.length) {
|
||||||
|
cancelPath();
|
||||||
|
}
|
||||||
|
if (canvas.style.cursor === "crosshair") {
|
||||||
|
canvas.style.cursor = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.cursor = "crosshair";
|
||||||
|
if (coords.length === 0) return;
|
||||||
|
|
||||||
|
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (getMode() !== "add-path") return;
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
finishPath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelPath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault();
|
||||||
|
removeLastVertex();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
cancelPath();
|
||||||
|
if (map.getCanvas().style.cursor === "crosshair") {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
cancel: cancelPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/uhm/lib/engine/pointEngine.ts
Normal file
45
src/uhm/lib/engine/pointEngine.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/uhm/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
// Khởi tạo engine thêm point bằng click đơn.
|
||||||
|
export function initPoint(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
// Thêm point mới khi đang ở chế độ add-point.
|
||||||
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "add-point") return;
|
||||||
|
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete?.(geometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cập nhật trạng thái con trỏ theo mode add-point.
|
||||||
|
function onMove() {
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
if (getMode() === "add-point") {
|
||||||
|
canvas.style.cursor = "crosshair";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canvas.style.cursor === "crosshair") {
|
||||||
|
canvas.style.cursor = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
if (map.getCanvas().style.cursor === "crosshair") {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
258
src/uhm/lib/engine/selectingEngine.ts
Normal file
258
src/uhm/lib/engine/selectingEngine.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
|
||||||
|
|
||||||
|
// Khởi tạo engine chọn feature và context menu edit/delete.
|
||||||
|
export function initSelect(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onDelete?: (id: string | number) => void,
|
||||||
|
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||||
|
onSelectId?: (id: string | number | null) => 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 = [
|
||||||
|
"countries",
|
||||||
|
"places",
|
||||||
|
"path-arrow-shapes",
|
||||||
|
] as const;
|
||||||
|
const selectedIds = new Set<number | string>();
|
||||||
|
const hasContextActions = Boolean(onDelete || onEdit);
|
||||||
|
let contextMenu: HTMLDivElement | null = null;
|
||||||
|
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||||
|
|
||||||
|
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
||||||
|
function clearSelection(emit = true) {
|
||||||
|
if (!selectedIds.size) return;
|
||||||
|
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
||||||
|
selectedIds.clear();
|
||||||
|
if (emit) {
|
||||||
|
onSelectId?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
|
||||||
|
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
|
||||||
|
const id = feature.id ?? feature.properties?.id;
|
||||||
|
if (id === undefined || id === null) return;
|
||||||
|
|
||||||
|
if (!additive) {
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additive && selectedIds.has(id)) {
|
||||||
|
// Alt + click on an already selected feature removes it from the selection
|
||||||
|
setSelectionStateForId(id, false);
|
||||||
|
selectedIds.delete(id);
|
||||||
|
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectionStateForId(id, true);
|
||||||
|
selectedIds.add(id);
|
||||||
|
onSelectId?.(selectedIds.size === 1 ? id : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
||||||
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "select") return;
|
||||||
|
const selectableLayers = getSelectableLayers();
|
||||||
|
if (!selectableLayers.length) return;
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: selectableLayers,
|
||||||
|
}) as maplibregl.MapGeoJSONFeature[];
|
||||||
|
|
||||||
|
if (!features.length) {
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additive = !!e.originalEvent?.altKey;
|
||||||
|
selectFeature(features[0], additive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "select") return;
|
||||||
|
const selectableLayers = getSelectableLayers();
|
||||||
|
if (!selectableLayers.length) return;
|
||||||
|
|
||||||
|
e.preventDefault(); // block browser menu
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: selectableLayers,
|
||||||
|
}) as maplibregl.MapGeoJSONFeature[];
|
||||||
|
|
||||||
|
if (!features.length) return;
|
||||||
|
|
||||||
|
const feature = features[0];
|
||||||
|
const id = feature.id ?? feature.properties?.id;
|
||||||
|
if (id === undefined || id === null) return;
|
||||||
|
|
||||||
|
// if right-clicked item not selected, make it the sole selection
|
||||||
|
if (!selectedIds.has(id)) {
|
||||||
|
clearSelection();
|
||||||
|
selectFeature(feature, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
showContextMenu(
|
||||||
|
e.originalEvent?.clientX ?? e.point.x,
|
||||||
|
e.originalEvent?.clientY ?? e.point.y,
|
||||||
|
feature
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
|
||||||
|
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "select") return;
|
||||||
|
const selectableLayers = getSelectableLayers();
|
||||||
|
if (!selectableLayers.length) {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: selectableLayers,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.getCanvas().style.cursor = features.length ? "pointer" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectableLayers(): string[] {
|
||||||
|
return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectionStateForId(id: string | number, selected: boolean) {
|
||||||
|
for (const source of FEATURE_STATE_SOURCES) {
|
||||||
|
if (!map.getSource(source)) continue;
|
||||||
|
map.setFeatureState({ source, id }, { selected });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
if (hasContextActions) {
|
||||||
|
map.on("contextmenu", onRightClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
if (hasContextActions) {
|
||||||
|
map.off("contextmenu", onRightClick);
|
||||||
|
}
|
||||||
|
clearSelection(false);
|
||||||
|
hideContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ẩn và dọn dẹp context menu hiện tại.
|
||||||
|
function hideContextMenu() {
|
||||||
|
if (contextMenu) {
|
||||||
|
contextMenu.remove();
|
||||||
|
contextMenu = null;
|
||||||
|
}
|
||||||
|
if (docClickHandler) {
|
||||||
|
document.removeEventListener("click", docClickHandler);
|
||||||
|
docClickHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render menu ngữ cảnh tối giản gần vị trí con trỏ.
|
||||||
|
function showContextMenu(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
clickedFeature: maplibregl.MapGeoJSONFeature
|
||||||
|
) {
|
||||||
|
hideContextMenu();
|
||||||
|
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.style.position = "fixed";
|
||||||
|
menu.style.left = `${x}px`;
|
||||||
|
menu.style.top = `${y}px`;
|
||||||
|
menu.style.background = "#0f172a";
|
||||||
|
menu.style.color = "white";
|
||||||
|
menu.style.border = "1px solid #1f2937";
|
||||||
|
menu.style.borderRadius = "6px";
|
||||||
|
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
|
||||||
|
menu.style.zIndex = "9999";
|
||||||
|
menu.style.minWidth = "120px";
|
||||||
|
menu.style.fontSize = "14px";
|
||||||
|
menu.style.padding = "4px 0";
|
||||||
|
|
||||||
|
// Tạo một item thao tác trong context menu.
|
||||||
|
const createItem = (label: string, onClick: () => void) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.textContent = label;
|
||||||
|
item.style.padding = "8px 12px";
|
||||||
|
item.style.cursor = "pointer";
|
||||||
|
item.onmouseenter = () => (item.style.background = "#1f2937");
|
||||||
|
item.onmouseleave = () => (item.style.background = "transparent");
|
||||||
|
item.onclick = () => {
|
||||||
|
onClick();
|
||||||
|
hideContextMenu();
|
||||||
|
};
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCount = selectedIds.size || 1;
|
||||||
|
let hasMenuItems = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedCount === 1 &&
|
||||||
|
clickedFeature.source === "countries" &&
|
||||||
|
clickedFeature.geometry?.type === "Polygon" &&
|
||||||
|
onEdit
|
||||||
|
) {
|
||||||
|
const single = clickedFeature;
|
||||||
|
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||||
|
hasMenuItems = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onDelete) {
|
||||||
|
menu.appendChild(
|
||||||
|
createItem(
|
||||||
|
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
|
||||||
|
() => {
|
||||||
|
const ids = selectedIds.size
|
||||||
|
? Array.from(selectedIds)
|
||||||
|
: [clickedFeature.id ?? clickedFeature.properties?.id];
|
||||||
|
ids.forEach((eachId) => {
|
||||||
|
if (eachId !== undefined && eachId !== null) onDelete(eachId);
|
||||||
|
});
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
hasMenuItems = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMenuItems) return;
|
||||||
|
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
contextMenu = menu;
|
||||||
|
|
||||||
|
// Đóng menu khi click ra ngoài vùng menu.
|
||||||
|
const onDocClick = (ev: MouseEvent) => {
|
||||||
|
if (!menu.contains(ev.target as Node)) {
|
||||||
|
hideContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
docClickHandler = onDocClick;
|
||||||
|
setTimeout(() => document.addEventListener("click", onDocClick), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/uhm/lib/entityTypeOptions.ts
Normal file
122
src/uhm/lib/entityTypeOptions.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
export type EntityTypeGroupId =
|
||||||
|
| "line"
|
||||||
|
| "polygon"
|
||||||
|
| "circle"
|
||||||
|
| "point";
|
||||||
|
|
||||||
|
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||||
|
|
||||||
|
export type EntityTypeGroup = {
|
||||||
|
id: EntityTypeGroupId;
|
||||||
|
label: string;
|
||||||
|
geometryLabel: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityTypeOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
groupId: EntityTypeGroupId;
|
||||||
|
groupLabel: string;
|
||||||
|
geometryPreset: EntityGeometryPreset;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
||||||
|
{
|
||||||
|
id: "line",
|
||||||
|
label: "line - Tuyến",
|
||||||
|
geometryLabel: "Line",
|
||||||
|
description: "Các tuyến line/path (gấp khúc).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "polygon",
|
||||||
|
label: "polygon - Đa giác",
|
||||||
|
geometryLabel: "Polygon",
|
||||||
|
description: "Vùng lãnh thổ dạng đa giác.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circle",
|
||||||
|
label: "circle - Tròn",
|
||||||
|
geometryLabel: "Circle",
|
||||||
|
description: "Vùng sự kiện theo bán kính ảnh hưởng.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "point",
|
||||||
|
label: "point - Điểm",
|
||||||
|
geometryLabel: "Point",
|
||||||
|
description: "Địa điểm đơn lẻ.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
|
||||||
|
line: ENTITY_TYPE_GROUPS[0],
|
||||||
|
polygon: ENTITY_TYPE_GROUPS[1],
|
||||||
|
circle: ENTITY_TYPE_GROUPS[2],
|
||||||
|
point: ENTITY_TYPE_GROUPS[3],
|
||||||
|
};
|
||||||
|
|
||||||
|
const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
groupId: EntityTypeGroupId;
|
||||||
|
geometryPreset: EntityGeometryPreset;
|
||||||
|
}> = [
|
||||||
|
{ value: "defense_line", label: "Defense Line", groupId: "line", geometryPreset: "line" },
|
||||||
|
|
||||||
|
{ value: "attack_route", label: "Attack Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "retreat_route", label: "Retreat Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "invasion_route", label: "Invasion Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "migration_route", label: "Migration Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "refugee_route", label: "Refugee Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "trade_route", label: "Trade Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
{ value: "shipping_route", label: "Shipping Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
|
||||||
|
{ value: "country", label: "Country", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
|
{ value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
|
{ value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
|
{ value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
|
|
||||||
|
{ value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
|
{ value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
|
{ value: "civilization", label: "Civilization", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
|
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
|
|
||||||
|
{ value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "person_activity", label: "Person Activity", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "temple", label: "Temple", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "capital", label: "Capital", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "city", label: "City", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "fortress", label: "Fortress", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "castle", label: "Castle", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "ruin", label: "Ruin", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "port", label: "Port", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "bridge", label: "Bridge", groupId: "point", geometryPreset: "point" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.map((item) => ({
|
||||||
|
...item,
|
||||||
|
groupLabel: GROUP_BY_ID[item.groupId].label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const DEFAULT_ENTITY_TYPE_ID = "country";
|
||||||
|
|
||||||
|
// Gom option theo group để render select phân nhóm.
|
||||||
|
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
|
||||||
|
id: EntityTypeGroupId;
|
||||||
|
label: string;
|
||||||
|
geometryLabel: string;
|
||||||
|
description: string;
|
||||||
|
options: EntityTypeOption[];
|
||||||
|
}> {
|
||||||
|
return ENTITY_TYPE_GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
options: options.filter((option) => option.groupId === group.id),
|
||||||
|
})).filter((group) => group.options.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if (!typeId) return null;
|
||||||
|
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
||||||
|
}
|
||||||
14
src/uhm/lib/geo/constants.ts
Normal file
14
src/uhm/lib/geo/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
|
||||||
|
export const WORLD_BBOX = {
|
||||||
|
minLng: -180,
|
||||||
|
minLat: -90,
|
||||||
|
maxLng: 180,
|
||||||
|
maxLat: 90,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EMPTY_FEATURE_COLLECTION: FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
13
src/uhm/lib/map/constants.ts
Normal file
13
src/uhm/lib/map/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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 MAP_MIN_ZOOM = 1;
|
||||||
|
export const MAP_MAX_ZOOM = 10;
|
||||||
|
|
||||||
|
export const RASTER_BASE_SOURCE_ID = "rasterBase";
|
||||||
|
export const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
||||||
|
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
||||||
|
|
||||||
|
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
||||||
|
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
||||||
76
src/uhm/lib/map/style.ts
Normal file
76
src/uhm/lib/map/style.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
|
||||||
|
export const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
|
"coalesce",
|
||||||
|
["get", "MAPCOLOR7"],
|
||||||
|
["get", "MAPCOLOR9"],
|
||||||
|
["get", "scalerank"],
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
|
"match",
|
||||||
|
COUNTRY_COLOR_KEY_EXPRESSION,
|
||||||
|
1, "#ef4444",
|
||||||
|
2, "#f97316",
|
||||||
|
3, "#f59e0b",
|
||||||
|
4, "#22c55e",
|
||||||
|
5, "#06b6d4",
|
||||||
|
6, "#3b82f6",
|
||||||
|
7, "#8b5cf6",
|
||||||
|
8, "#a855f7",
|
||||||
|
9, "#d946ef",
|
||||||
|
10, "#14b8a6",
|
||||||
|
"#64748b",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const POLYGON_FILL_BY_TYPE: Record<string, string> = {
|
||||||
|
country: "#2563eb",
|
||||||
|
state: "#0ea5e9",
|
||||||
|
empire: "#f59e0b",
|
||||||
|
kingdom: "#d97706",
|
||||||
|
war: "#dc2626",
|
||||||
|
battle: "#f43f5e",
|
||||||
|
civilization: "#14b8a6",
|
||||||
|
rebellion_zone: "#7c3aed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
|
||||||
|
country: "#1e3a8a",
|
||||||
|
state: "#0c4a6e",
|
||||||
|
empire: "#7c2d12",
|
||||||
|
kingdom: "#9a3412",
|
||||||
|
war: "#7f1d1d",
|
||||||
|
battle: "#9f1239",
|
||||||
|
civilization: "#134e4a",
|
||||||
|
rebellion_zone: "#4c1d95",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
|
||||||
|
war: 0.3,
|
||||||
|
battle: 0.34,
|
||||||
|
civilization: 0.38,
|
||||||
|
rebellion_zone: 0.32,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
||||||
|
defense_line: "#f97316",
|
||||||
|
attack_route: "#ef4444",
|
||||||
|
retreat_route: "#94a3b8",
|
||||||
|
invasion_route: "#b91c1c",
|
||||||
|
migration_route: "#0ea5e9",
|
||||||
|
refugee_route: "#06b6d4",
|
||||||
|
trade_route: "#eab308",
|
||||||
|
shipping_route: "#2563eb",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
|
||||||
|
attack_route: true,
|
||||||
|
retreat_route: true,
|
||||||
|
invasion_route: true,
|
||||||
|
migration_route: true,
|
||||||
|
refugee_route: true,
|
||||||
|
trade_route: true,
|
||||||
|
shipping_route: true,
|
||||||
|
};
|
||||||
|
|
||||||
25
src/uhm/lib/timeline.ts
Normal file
25
src/uhm/lib/timeline.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
// Single source of truth for the app-wide timeline range.
|
||||||
|
export const FIXED_TIMELINE_START_YEAR = -2000;
|
||||||
|
export const FIXED_TIMELINE_END_YEAR = 2000;
|
||||||
|
|
||||||
|
export const FIXED_TIMELINE_RANGE: TimelineRange = {
|
||||||
|
min: FIXED_TIMELINE_START_YEAR,
|
||||||
|
max: FIXED_TIMELINE_END_YEAR,
|
||||||
|
};
|
||||||
|
|
||||||
|
// UI debounce when user drags timeline before triggering data fetch.
|
||||||
|
export const TIMELINE_DEBOUNCE_MS = 180;
|
||||||
|
|
||||||
|
export function clampYearValue(year: number, minYear: number, maxYear: number): number {
|
||||||
|
const lower = Math.min(minYear, maxYear);
|
||||||
|
const upper = Math.max(minYear, maxYear);
|
||||||
|
if (year < lower) return lower;
|
||||||
|
if (year > upper) return upper;
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampYearToFixedRange(year: number): number {
|
||||||
|
return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR);
|
||||||
|
}
|
||||||
51
src/uhm/lib/useEditorSessionState.ts
Normal file
51
src/uhm/lib/useEditorSessionState.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
|
import { useBackgroundSessionState } from "@/uhm/lib/editor/session/useBackgroundSessionState";
|
||||||
|
import { useEntitySessionState } from "@/uhm/lib/editor/session/useEntitySessionState";
|
||||||
|
import { useSectionSessionState } from "@/uhm/lib/editor/session/useSectionSessionState";
|
||||||
|
import { useTimelineState } from "@/uhm/lib/editor/session/useTimelineState";
|
||||||
|
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CreatedEntitySummary,
|
||||||
|
EditorMode,
|
||||||
|
EntityFormState,
|
||||||
|
GeometryMetaFormState,
|
||||||
|
PendingEntityCreate,
|
||||||
|
TimelineRange,
|
||||||
|
} from "@/uhm/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
emptyFeatureCollection: FeatureCollection;
|
||||||
|
defaultEditorUserId: string;
|
||||||
|
fallbackTimelineRange: TimelineRange;
|
||||||
|
currentYear: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useEditorSessionState(options: Options) {
|
||||||
|
// Mode thao tác map/editor hiện tại.
|
||||||
|
const [mode, setMode] = useState<EditorMode>("idle");
|
||||||
|
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc section snapshot).
|
||||||
|
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
|
||||||
|
|
||||||
|
const section = useSectionSessionState({
|
||||||
|
defaultEditorUserId: options.defaultEditorUserId,
|
||||||
|
});
|
||||||
|
const entity = useEntitySessionState();
|
||||||
|
const timeline = useTimelineState({
|
||||||
|
currentYear: options.currentYear,
|
||||||
|
fallbackTimelineRange: options.fallbackTimelineRange,
|
||||||
|
});
|
||||||
|
const background = useBackgroundSessionState();
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
initialData,
|
||||||
|
setInitialData,
|
||||||
|
...section,
|
||||||
|
...entity,
|
||||||
|
...timeline,
|
||||||
|
...background,
|
||||||
|
};
|
||||||
|
}
|
||||||
186
src/uhm/lib/useEditorState.ts
Normal file
186
src/uhm/lib/useEditorState.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type {
|
||||||
|
Feature,
|
||||||
|
FeatureCollection,
|
||||||
|
FeatureProperties,
|
||||||
|
Geometry,
|
||||||
|
} from "@/uhm/types/geo";
|
||||||
|
import { buildInitialMap, deepClone, diffDraftToInitial } from "@/uhm/lib/editor/draft/draftDiff";
|
||||||
|
import { useDraftState } from "@/uhm/lib/editor/draft/useDraftState";
|
||||||
|
import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
|
||||||
|
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
|
||||||
|
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
|
||||||
|
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
|
|
||||||
|
// State trung tâm của editor:
|
||||||
|
// - draft: dữ liệu nguồn để render UI
|
||||||
|
// - changes: map các thay đổi chờ lưu
|
||||||
|
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
||||||
|
export function useEditorState(initialData: FeatureCollection) {
|
||||||
|
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
||||||
|
|
||||||
|
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
|
||||||
|
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||||
|
buildInitialMap(initialData)
|
||||||
|
);
|
||||||
|
// Version counter để ép diff recalculation sau khi reset/clear baseline.
|
||||||
|
const [baselineVersion, setBaselineVersion] = useState(0);
|
||||||
|
|
||||||
|
const applyUndoAction = useCallback((action: UndoAction): boolean => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "create": {
|
||||||
|
commitDraft({
|
||||||
|
...draftRef.current,
|
||||||
|
features: draftRef.current.features.filter((feature) =>
|
||||||
|
feature.properties.id !== action.id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "delete": {
|
||||||
|
const feature = deepClone(action.feature);
|
||||||
|
commitDraft({
|
||||||
|
...draftRef.current,
|
||||||
|
features: [...draftRef.current.features, feature],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "update": {
|
||||||
|
const idx = draftRef.current.features.findIndex((feature) =>
|
||||||
|
feature.properties.id === action.id
|
||||||
|
);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures[idx] = {
|
||||||
|
...nextFeatures[idx],
|
||||||
|
geometry: deepClone(action.prevGeometry),
|
||||||
|
};
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "properties": {
|
||||||
|
const idx = draftRef.current.features.findIndex((feature) =>
|
||||||
|
feature.properties.id === action.id
|
||||||
|
);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures[idx] = {
|
||||||
|
...nextFeatures[idx],
|
||||||
|
properties: deepClone(action.prevProperties),
|
||||||
|
};
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [commitDraft, draftRef]);
|
||||||
|
|
||||||
|
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetDraft(deepClone(initialData));
|
||||||
|
clearUndo();
|
||||||
|
initialMapRef.current = buildInitialMap(initialData);
|
||||||
|
setBaselineVersion((version) => version + 1);
|
||||||
|
}, [clearUndo, initialData, resetDraft]);
|
||||||
|
|
||||||
|
const changes = useMemo(() => {
|
||||||
|
const baseline = initialMapRef.current;
|
||||||
|
return diffDraftToInitial(draft, baseline);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [draft, baselineVersion]);
|
||||||
|
const changeCount = useMemo(() => changes.size, [changes]);
|
||||||
|
|
||||||
|
function createFeature(feature: Feature) {
|
||||||
|
const featureClone = deepClone(feature);
|
||||||
|
commitDraft({
|
||||||
|
...draftRef.current,
|
||||||
|
features: [...draftRef.current.features, featureClone],
|
||||||
|
});
|
||||||
|
pushUndo({ type: "create", id: featureClone.properties.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFeatureProperties(
|
||||||
|
id: FeatureProperties["id"],
|
||||||
|
patch: Partial<FeatureProperties>
|
||||||
|
) {
|
||||||
|
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
const prevProperties = deepClone(nextFeatures[idx].properties);
|
||||||
|
nextFeatures[idx] = {
|
||||||
|
...nextFeatures[idx],
|
||||||
|
properties: {
|
||||||
|
...nextFeatures[idx].properties,
|
||||||
|
...deepClone(patch),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (JSON.stringify(prevProperties) === JSON.stringify(nextFeatures[idx].properties)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUndo({ type: "properties", id, prevProperties });
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||||
|
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const prevFeature = draftRef.current.features[idx];
|
||||||
|
const prevGeometry = deepClone(prevFeature.geometry);
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures[idx] = {
|
||||||
|
...prevFeature,
|
||||||
|
geometry: deepClone(newGeometry),
|
||||||
|
};
|
||||||
|
|
||||||
|
pushUndo({ type: "update", id, prevGeometry });
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFeature(id: FeatureProperties["id"]) {
|
||||||
|
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const feature = draftRef.current.features[idx];
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures.splice(idx, 1);
|
||||||
|
|
||||||
|
pushUndo({ type: "delete", feature: deepClone(feature) });
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(): Change[] {
|
||||||
|
return Array.from(changes.values()).map((change) => deepClone(change));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChanges() {
|
||||||
|
clearUndo();
|
||||||
|
initialMapRef.current = buildInitialMap(draftRef.current);
|
||||||
|
setBaselineVersion((version) => version + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPersistedFeature(id: FeatureProperties["id"]) {
|
||||||
|
return initialMapRef.current.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
draft,
|
||||||
|
changes,
|
||||||
|
undoStack,
|
||||||
|
changeCount,
|
||||||
|
createFeature,
|
||||||
|
patchFeatureProperties,
|
||||||
|
updateFeature,
|
||||||
|
deleteFeature,
|
||||||
|
undo,
|
||||||
|
buildPayload,
|
||||||
|
clearChanges,
|
||||||
|
hasPersistedFeature,
|
||||||
|
};
|
||||||
|
}
|
||||||
18
src/uhm/types/api.ts
Normal file
18
src/uhm/types/api.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export type ApiEnvelope<T> = {
|
||||||
|
// API cũ: "success" | "error"
|
||||||
|
// API mới: boolean (true/false)
|
||||||
|
status: boolean | "success" | "error" | string;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
errors?: unknown;
|
||||||
|
pagination?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometriesBBoxQuery = {
|
||||||
|
minLng: number;
|
||||||
|
minLat: number;
|
||||||
|
maxLng: number;
|
||||||
|
maxLat: number;
|
||||||
|
time?: number;
|
||||||
|
entity_id?: string;
|
||||||
|
};
|
||||||
31
src/uhm/types/entities.ts
Normal file
31
src/uhm/types/entities.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export type Entity = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// API mới
|
||||||
|
description?: string | null;
|
||||||
|
thumbnail_url?: string | null;
|
||||||
|
is_deleted?: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
|
||||||
|
// API cũ / snapshot editor (giữ optional để không phá flow editor snapshot)
|
||||||
|
slug?: string | null;
|
||||||
|
type_id?: string | null;
|
||||||
|
status?: number | null;
|
||||||
|
geometry_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
|
||||||
|
|
||||||
|
export type EntitySnapshot = {
|
||||||
|
id: string;
|
||||||
|
operation: EntitySnapshotOperation;
|
||||||
|
name?: string;
|
||||||
|
slug?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
type_id?: string | null;
|
||||||
|
status?: number | null;
|
||||||
|
is_deleted?: number;
|
||||||
|
base_updated_at?: string;
|
||||||
|
base_hash?: string;
|
||||||
|
};
|
||||||
70
src/uhm/types/geo.ts
Normal file
70
src/uhm/types/geo.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { EntityGeometryPreset } from "@/uhm/lib/entityTypeOptions";
|
||||||
|
|
||||||
|
export type Geometry =
|
||||||
|
| { type: "Point"; coordinates: [number, number] }
|
||||||
|
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||||
|
| { type: "LineString"; coordinates: [number, number][] }
|
||||||
|
| { type: "MultiLineString"; coordinates: [number, number][][] }
|
||||||
|
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||||
|
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||||
|
|
||||||
|
export type FeatureId = string | number;
|
||||||
|
|
||||||
|
export type FeatureProperties = {
|
||||||
|
id: FeatureId;
|
||||||
|
type?: string | null;
|
||||||
|
geometry_preset?: EntityGeometryPreset | null;
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
binding?: string[];
|
||||||
|
entity_id?: string | null;
|
||||||
|
entity_ids?: string[];
|
||||||
|
entity_name?: string | null;
|
||||||
|
entity_names?: string[];
|
||||||
|
entity_type_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Feature = {
|
||||||
|
type: "Feature";
|
||||||
|
properties: FeatureProperties;
|
||||||
|
geometry: Geometry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureCollection = {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: Feature[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
|
||||||
|
|
||||||
|
export type GeometrySnapshot = {
|
||||||
|
id: string;
|
||||||
|
operation: GeometrySnapshotOperation;
|
||||||
|
type?: string | null;
|
||||||
|
draw_geometry?: Geometry;
|
||||||
|
geometry?: Geometry;
|
||||||
|
binding?: string[];
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
bbox?: {
|
||||||
|
min_lng: number;
|
||||||
|
min_lat: number;
|
||||||
|
max_lng: number;
|
||||||
|
max_lat: number;
|
||||||
|
} | null;
|
||||||
|
is_deleted?: number;
|
||||||
|
base_updated_at?: string;
|
||||||
|
base_hash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LinkScopeSnapshot = {
|
||||||
|
geometry_id: string;
|
||||||
|
operation: "replace" | "reference";
|
||||||
|
entity_ids: string[];
|
||||||
|
base_links_hash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometryChange =
|
||||||
|
| { action: "create"; feature: Feature }
|
||||||
|
| { action: "update"; id: FeatureId; geometry: Geometry }
|
||||||
|
| { action: "delete"; id: FeatureId };
|
||||||
87
src/uhm/types/sections.ts
Normal file
87
src/uhm/types/sections.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
|
||||||
|
|
||||||
|
// API mới (BackEndGo) dùng Projects/Commits/Submissions.
|
||||||
|
// Giữ tên type "Section" để tránh thay đổi lan rộng trong FE hiện tại.
|
||||||
|
export type SectionStatus = string;
|
||||||
|
export type SectionSubmissionStatus = "PENDING" | "APPROVED" | "REJECTED" | string;
|
||||||
|
|
||||||
|
export type SectionState = {
|
||||||
|
// Derived state from ProjectResponse (not persisted as-is in API mới).
|
||||||
|
status: SectionStatus;
|
||||||
|
head_commit_id: string | null;
|
||||||
|
locked_by?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Section = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
project_status?: string;
|
||||||
|
latest_commit_id?: string | null;
|
||||||
|
submission_ids?: string[];
|
||||||
|
locked_by?: string | null;
|
||||||
|
user_id?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
state?: {
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SectionCommit = {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
snapshot_json: EditorSnapshot;
|
||||||
|
snapshot_hash: string;
|
||||||
|
user_id: string;
|
||||||
|
edit_summary: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SectionSubmission = {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
commit_id: string;
|
||||||
|
user_id: string;
|
||||||
|
created_at?: string;
|
||||||
|
status: SectionSubmissionStatus;
|
||||||
|
reviewed_by?: string | null;
|
||||||
|
reviewed_at?: string | null;
|
||||||
|
review_note?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditorSnapshot = {
|
||||||
|
schema_version: number;
|
||||||
|
section: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
editor_feature_collection?: FeatureCollection;
|
||||||
|
entities?: EntitySnapshot[];
|
||||||
|
geometries?: GeometrySnapshot[];
|
||||||
|
link_scopes?: LinkScopeSnapshot[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditorLoadResponse = {
|
||||||
|
section: Section;
|
||||||
|
state: SectionState;
|
||||||
|
commit: SectionCommit | null;
|
||||||
|
snapshot: EditorSnapshot | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateSectionInput = {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
status?: "PRIVATE" | "PUBLIC" | "ARCHIVE";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateCommitInput = {
|
||||||
|
snapshot: EditorSnapshot;
|
||||||
|
edit_summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RestoreCommitInput = {
|
||||||
|
commit_id: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user