diff --git a/package-lock.json b/package-lock.json index f20bfbb..12a6fc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "ezuikit-js": "^0.7.2", "gsap": "^3.12.2", "normalize.css": "^8.0.1", + "ol": "^10.9.0", "point-in-polygon": "^1.1.0", "shpjs": "^6.2.0", "three": "0.161.0", @@ -768,6 +769,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "license": "MIT" + }, + "node_modules/@types/rbush": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", + "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", + "license": "MIT" + }, "node_modules/@types/stats.js": { "version": "0.17.4", "resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz", @@ -915,6 +928,16 @@ "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==", "license": "MIT" }, + "node_modules/@zarrita/storage": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz", + "integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==", + "license": "MIT", + "dependencies": { + "reference-spec-reader": "^0.2.0", + "unzipit": "2.0.0" + } + }, "node_modules/autofit.js": { "version": "3.2.8", "resolved": "https://registry.npmmirror.com/autofit.js/-/autofit.js-3.2.8.tgz", @@ -993,6 +1016,12 @@ "node": ">=8" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/echarts": { "version": "5.6.0", "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", @@ -1087,6 +1116,25 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/geotiff": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", + "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.9.3", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.5.0", + "xml-utils": "^1.10.2", + "zstddec": "^0.2.0" + }, + "engines": { + "node": ">=10.19" + } + }, "node_modules/gsap": { "version": "3.14.2", "resolved": "https://registry.npmmirror.com/gsap/-/gsap-3.14.2.tgz", @@ -1134,6 +1182,12 @@ "node": ">=0.10.0" } }, + "node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", + "license": "Apache-2.0" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", @@ -1188,12 +1242,79 @@ "integrity": "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==", "license": "MIT" }, + "node_modules/numcodecs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", + "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.0" + } + }, + "node_modules/numcodecs/node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/ol": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz", + "integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/rbush": "4.0.0", + "earcut": "^3.0.0", + "geotiff": "^3.0.5 || ^3.1.0-beta.0", + "pbf": "4.0.1", + "rbush": "^4.0.0", + "zarrita": "^0.7.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/openlayers" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, "node_modules/parsedbf": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/parsedbf/-/parsedbf-2.0.0.tgz", "integrity": "sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==", "license": "MIT" }, + "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": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -1269,6 +1390,39 @@ } } }, + "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/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", @@ -1283,12 +1437,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reference-spec-reader": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", + "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", + "license": "MIT" + }, "node_modules/resize-detector": { "version": "0.3.0", "resolved": "https://registry.npmmirror.com/resize-detector/-/resize-detector-0.3.0.tgz", "integrity": "sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ==", "license": "MIT" }, + "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/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -1380,6 +1549,15 @@ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", "license": "0BSD" }, + "node_modules/unzipit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz", + "integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz", @@ -1536,6 +1714,12 @@ "vue": "^3.5.0" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/wkt-parser": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.5.tgz", @@ -1545,6 +1729,22 @@ "url": "https://github.com/sponsors/ahocevar" } }, + "node_modules/xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", + "license": "CC0-1.0" + }, + "node_modules/zarrita": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz", + "integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==", + "license": "MIT", + "dependencies": { + "@zarrita/storage": "^0.2.0", + "numcodecs": "^0.3.2" + } + }, "node_modules/zrender": { "version": "5.6.1", "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", @@ -1553,6 +1753,12 @@ "dependencies": { "tslib": "2.3.0" } + }, + "node_modules/zstddec": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", + "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", + "license": "MIT AND BSD-3-Clause" } } } diff --git a/package.json b/package.json index 6e4b326..64f70d4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "ezuikit-js": "^0.7.2", "gsap": "^3.12.2", "normalize.css": "^8.0.1", + "ol": "^10.9.0", "point-in-polygon": "^1.1.0", "shpjs": "^6.2.0", "three": "0.161.0", diff --git a/src/router/index.js b/src/router/index.js index f36c0ef..25c4c8d 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -3,6 +3,7 @@ import { fetchCurrentUser, getAuthorizationToken, getLoginPath } from "@/service const platform = () => import("@/views/platform/index.vue") const login = () => import("@/views/login/index.vue") +const chartStyleGuide = () => import("@/views/chartStyleGuide/index.vue") const router = createRouter({ history: createWebHashHistory(), routes: [ @@ -23,6 +24,10 @@ const router = createRouter({ path: "/forest-grass-wet", component: platform, }, + { + path: "/chart-style-guide", + component: chartStyleGuide, + }, { path: "/:pathMatch(.*)", diff --git a/src/services/ndviTimeline.js b/src/services/ndviTimeline.js new file mode 100644 index 0000000..daaa008 --- /dev/null +++ b/src/services/ndviTimeline.js @@ -0,0 +1,72 @@ +const NDVI_TIMELINE_URL = "/jsyApi/api/map/ndvi_timeline" + +export async function getGrasslandNdviTimeline() { + const response = await fetch(NDVI_TIMELINE_URL, { + credentials: "include", + headers: { + Accept: "application/json", + }, + }) + if (!response.ok) { + const error = new Error(`HTTP ${response.status}`) + error.status = response.status + throw error + } + const data = await response.json().catch(() => ({})) + if (data?.code === 401 || data?.code === "401") { + const error = new Error(data?.msg || data?.message || "NDVI 时间轴未授权") + error.status = 401 + throw error + } + const rows = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [] + return normalizeNdviTimelineRows(rows) +} + +function normalizeNdviTimelineRows(rows = []) { + const seen = new Set() + return rows + .map((row, index) => { + const layerName = normalizeTimelineLayerName(pickValue(row, ["url", "layerName", "layer_name", "layer", "name"])) + if (!layerName) return null + const rawDate = pickValue(row, ["date", "month", "time", "label"]) || layerName + const label = formatTimelineLabel(rawDate) + const key = `${layerName}-${rawDate || index}` + return { + key, + label, + date: String(rawDate), + layerName, + source: row, + } + }) + .filter(Boolean) + .filter((item) => { + if (seen.has(item.key)) return false + seen.add(item.key) + return true + }) +} + +function pickValue(item, keys) { + for (const key of keys) { + if (item?.[key] !== undefined && item?.[key] !== null && item?.[key] !== "") { + return item[key] + } + } + return undefined +} + +function normalizeTimelineLayerName(value) { + const text = String(value || "").trim() + if (!text) return "" + if (text.includes(":") || /^https?:\/\//i.test(text)) return text + return `ne:${text}` +} + +function formatTimelineLabel(value) { + const text = String(value || "").trim() + if (!text) return "" + const match = text.match(/(\d{4})[-_/年. ]?(0?[1-9]|1[0-2])(?:月)?/) + if (match) return `${match[1]}.${String(Number(match[2])).padStart(2, "0")}` + return text +} diff --git a/src/utils/chartTheme.js b/src/utils/chartTheme.js index 4249669..8c19889 100644 --- a/src/utils/chartTheme.js +++ b/src/utils/chartTheme.js @@ -124,11 +124,238 @@ export function withChartDefaults(option = {}) { return normalized } +export function createSystemBarOption({ + categories = [], + rows = [], + unit = "", + name = "指标", + orientation = "vertical", + colorIndex = 0, + grid = {}, + showLabel = true, +} = {}) { + const color = chartPalette[colorIndex % chartPalette.length] + const horizontal = orientation === "horizontal" + const data = rows.map((item, index) => ({ + name: item.name || categories[index], + value: Number(item.value) || 0, + itemStyle: { + borderRadius: horizontal ? [0, 8, 8, 0] : [8, 8, 0, 0], + color: horizontal ? horizontalGradient(item.color || color, "rgba(48, 220, 255, 0.14)", item.color || color) : verticalGradient(item.color || color), + }, + })) + + return withChartDefaults({ + color: chartPalette, + grid: chartGrid(horizontal ? { left: 72, right: showLabel ? 58 : 26, top: 24, bottom: 22, ...grid } : { left: 46, right: 24, top: 28, bottom: 42, ...grid }), + tooltip: chartTooltip((params) => `${params.marker}${params.name}:${formatChartValue(params.value)}${unit}`), + xAxis: horizontal ? valueAxis() : categoryAxis(categories, { axisLabel: { rotate: categories.length > 6 ? 24 : 0 } }), + yAxis: horizontal ? categoryAxis(categories, { inverse: true, axisLabel: { color: "rgba(225, 249, 255, 0.82)" } }) : valueAxis(), + series: [ + { + name, + type: "bar", + barWidth: horizontal ? 12 : categories.length > 7 ? 14 : 20, + data, + label: { + show: showLabel, + position: horizontal ? "right" : "top", + color: "#f7feff", + fontSize: 10, + formatter: ({ value }) => formatChartValue(value), + }, + }, + ], + }) +} + +export function createSystemGroupedBarOption({ categories = [], series = [], unit = "", grid = {} } = {}) { + return withChartDefaults({ + color: chartPalette, + grid: chartGrid({ left: 46, right: 20, top: 42, bottom: 42, ...grid }), + tooltip: chartTooltip((params) => formatAxisTooltip(params, unit), { trigger: "axis", axisPointer: { type: "shadow" } }), + legend: chartLegend({ top: 0, right: 4 }), + xAxis: categoryAxis(categories), + yAxis: valueAxis(), + series: series.map((item, index) => ({ + name: item.name, + type: "bar", + barWidth: 10, + data: item.values || [], + itemStyle: { + borderRadius: [6, 6, 0, 0], + color: verticalGradient(chartPalette[index % chartPalette.length]), + }, + })), + }) +} + +export function createSystemStackedBarOption({ categories = [], series = [], unit = "", grid = {} } = {}) { + return withChartDefaults({ + color: chartPalette, + grid: chartGrid({ left: 48, right: 22, top: 42, bottom: 42, ...grid }), + tooltip: chartTooltip((params) => formatAxisTooltip(params, unit), { trigger: "axis", axisPointer: { type: "shadow" } }), + legend: chartLegend({ top: 0, right: 4 }), + xAxis: categoryAxis(categories), + yAxis: valueAxis(), + series: series.map((item, index) => ({ + name: item.name, + type: "bar", + stack: "total", + barWidth: 18, + data: item.values || [], + itemStyle: { + color: verticalGradient(chartPalette[index % chartPalette.length]), + }, + })), + }) +} + +export function createSystemLineOption({ categories = [], series = [], unit = "", area = false, grid = {} } = {}) { + return withChartDefaults({ + color: chartPalette, + grid: chartGrid({ left: 46, right: 24, top: 42, bottom: 38, ...grid }), + tooltip: chartTooltip((params) => formatAxisTooltip(params, unit), { + trigger: "axis", + axisPointer: { + type: "line", + lineStyle: { color: "rgba(255, 224, 107, 0.54)" }, + }, + }), + legend: chartLegend({ top: 0, right: 4 }), + xAxis: categoryAxis(categories), + yAxis: valueAxis(), + series: series.map((item, index) => { + const color = item.color || chartPalette[index % chartPalette.length] + return { + name: item.name, + type: "line", + smooth: true, + symbol: "circle", + symbolSize: 7, + data: item.values || [], + lineStyle: { + width: 3, + color, + shadowColor: `${color}55`, + shadowBlur: 10, + }, + itemStyle: { + color: "#041522", + borderColor: color, + borderWidth: 2, + }, + areaStyle: area + ? { + color: verticalGradient(color, `${color}08`), + } + : undefined, + } + }), + }) +} + +export function createSystemPieOption({ rows = [], unit = "", name = "结构占比", rose = false, donut = true } = {}) { + return withChartDefaults({ + color: chartPalette, + tooltip: chartTooltip((params) => `${params.marker}${params.name}:${formatChartValue(params.value)}${unit}
占比:${formatChartValue(params.percent)}%`), + legend: chartLegend({ + orient: "vertical", + right: 6, + top: "middle", + itemWidth: 9, + itemHeight: 9, + }), + series: [ + { + name, + type: "pie", + radius: donut ? ["45%", rose ? "74%" : "66%"] : rose ? [12, "72%"] : "64%", + center: ["36%", "52%"], + roseType: rose ? "radius" : undefined, + avoidLabelOverlap: true, + itemStyle: { + borderColor: "rgba(3, 18, 31, 0.94)", + borderWidth: 3, + }, + label: { + color: "#f7feff", + fontSize: 10, + formatter: "{b}\n{d}%", + }, + labelLine: { + length: 8, + length2: 8, + lineStyle: { color: "rgba(223, 250, 255, 0.42)" }, + }, + data: rows.map((row, index) => ({ + name: row.name, + value: Number(row.value) || 0, + itemStyle: { color: row.color || chartPalette[index % chartPalette.length] }, + })), + }, + ], + }) +} + +export function createSystemRadarOption({ indicators = [], rows = [], name = "综合评价" } = {}) { + return withChartDefaults({ + color: chartPalette, + tooltip: chartTooltip(), + legend: chartLegend({ top: 0, right: 4 }), + radar: { + center: ["50%", "58%"], + radius: "62%", + indicator: indicators, + splitNumber: 4, + axisName: { + color: "rgba(223, 250, 255, 0.78)", + fontSize: 10, + }, + axisLine: { lineStyle: { color: "rgba(48, 220, 255, 0.18)" } }, + splitLine: { lineStyle: { color: "rgba(48, 220, 255, 0.16)" } }, + splitArea: { + areaStyle: { + color: ["rgba(48, 220, 255, 0.04)", "rgba(36, 246, 196, 0.02)"], + }, + }, + }, + series: [ + { + name, + type: "radar", + data: rows.map((row, index) => ({ + name: row.name, + value: row.values || [], + areaStyle: { color: `${chartPalette[index % chartPalette.length]}24` }, + lineStyle: { color: chartPalette[index % chartPalette.length], width: 2 }, + itemStyle: { color: chartPalette[index % chartPalette.length] }, + })), + }, + ], + }) +} + +export function formatChartValue(value) { + const number = Number(value) + if (!Number.isFinite(number)) return "--" + return Number.isInteger(number) ? number.toLocaleString("zh-CN") : number.toLocaleString("zh-CN", { maximumFractionDigits: 2 }) +} + function stripAxisOverrides(overrides = {}) { const { axisLabel, axisLine, axisTick, splitLine, ...rest } = overrides return rest } +function formatAxisTooltip(params, unit = "") { + const rows = Array.isArray(params) ? params : [params] + const title = rows[0]?.axisValueLabel || rows[0]?.name || "" + const body = rows + .map((item) => `${item.marker}${item.seriesName || item.name}:${formatChartValue(item.value)}${unit}`) + .join("
") + return `${title}
${body}` +} + function normalizeChartTypography(target) { if (!target || typeof target !== "object") return const styleKeys = ["textStyle", "axisLabel", "nameTextStyle", "label", "axisName"] diff --git a/src/views/chartStyleGuide/index.vue b/src/views/chartStyleGuide/index.vue new file mode 100644 index 0000000..5d053c0 --- /dev/null +++ b/src/views/chartStyleGuide/index.vue @@ -0,0 +1,894 @@ + + + + + diff --git a/src/views/ecologicalProtection/data.js b/src/views/ecologicalProtection/data.js index dd6b2de..d0b9a3c 100644 --- a/src/views/ecologicalProtection/data.js +++ b/src/views/ecologicalProtection/data.js @@ -285,13 +285,15 @@ export const ecologicalProtectionMenu = [ ] export const parkTownRows = [ - { name: "麦洼乡", grasslandAreaWanMu: 31.41, wetlandAreaWanMu: 11.16, ecologyJobs: 179, yakCount: 61720, livestockCount: 63507, cover: 85, villages: "洞拉村、滚塘村、让里村、茸塔玛村" }, - { name: "瓦切镇", grasslandAreaWanMu: 31.42, wetlandAreaWanMu: 11.15, ecologyJobs: 160, yakCount: 33887, livestockCount: 34589, cover: 85, villages: "唐日村、德香村、日干村" }, - { name: "色地镇", grasslandAreaWanMu: 31.42, wetlandAreaWanMu: 11.16, ecologyJobs: 39, yakCount: 9572, livestockCount: 9843, cover: 86, villages: "色永村" }, + { name: "麦洼乡", protectedAreaWanMu: 65.94, coreProtectionAreaWanMu: 28.69, generalControlAreaWanMu: 37.25, grasslandAreaWanMu: 31.41, wetlandAreaWanMu: 11.16, ecologyJobs: 179, yakCount: 61720, livestockCount: 63507, cover: 85, villages: "洞拉村、滚塘村、让里村、茸塔玛村" }, + { name: "瓦切镇", protectedAreaWanMu: 74.44, coreProtectionAreaWanMu: 31.1, generalControlAreaWanMu: 43.34, grasslandAreaWanMu: 31.42, wetlandAreaWanMu: 11.15, ecologyJobs: 160, yakCount: 33887, livestockCount: 34589, cover: 85, villages: "唐日村、德香村、日干村" }, + { name: "色地镇", protectedAreaWanMu: 20.93, coreProtectionAreaWanMu: 7.6, generalControlAreaWanMu: 13.33, grasslandAreaWanMu: 31.42, wetlandAreaWanMu: 11.16, ecologyJobs: 39, yakCount: 9572, livestockCount: 9843, cover: 86, villages: "色永村" }, ] export const parkSummary = { name: "若尔盖国家公园红原区域", + nationalParkTotalAreaWanMu: 2074.39, + hongyuanDelimitedAreaWanMu: 161.31, totalAreaWanMu: 161.31, grasslandAreaWanMu: 94.25, wetlandAreaWanMu: 33.47, @@ -311,9 +313,9 @@ parkSummary.otherProtectedAreaWanMu = round( ) export const parkCoreRows = [ - { key: "park-total-area", name: "保护区总面积", value: parkSummary.totalAreaWanMu, unit: "万亩", icon: "area" }, - { key: "park-grassland", name: "草地面积", value: parkSummary.grasslandAreaWanMu, unit: "万亩", icon: "leaf" }, - { key: "park-wetland", name: "湿地面积", value: parkSummary.wetlandAreaWanMu, unit: "万亩", icon: "stack" }, + { key: "park-national-area", name: "国家公园总面积", value: parkSummary.nationalParkTotalAreaWanMu, unit: "万亩", icon: "area" }, + { key: "park-hongyuan-area", name: "红原县划定面积", value: parkSummary.hongyuanDelimitedAreaWanMu, unit: "万亩", icon: "area" }, + { key: "park-yak-count", name: "牦牛数量", value: parkSummary.yakCount, unit: "头", icon: "stack" }, ] const parkResourceStructureRows = [ @@ -339,12 +341,13 @@ const parkTownListRows = parkTownRows.map((item) => ({ })) const parkZoningRows = [ - { key: "core-protection", name: "核心保护区", value: 67.36, unit: "万亩" }, - { key: "general-control", name: "一般控制区", value: 93.95, unit: "万亩" }, + { key: "core-protection", name: "核心保护区", value: 67.39, unit: "万亩" }, + { key: "general-control", name: "一般控制区", value: 93.92, unit: "万亩" }, ] const parkGrasslandProductivityWanTon = 43.66 const parkEdibleGrasslandWanTon = round(parkGrasslandProductivityWanTon * (192.26 / 297.29), 2) +const parkGrasslandCapacityPerMu = 0.06229833103624831 const parkVillageWeights = { 麦洼乡: [ { name: "洞拉村", grassland: 0.29, wetland: 0.34, household: 0.3, yak: 0.32, edible: 0.31, woodland: 0.3 }, @@ -364,6 +367,12 @@ const parkVillageWeights = { const parkTownLivestockTotal = sumBy(parkTownRows, "livestockCount") || sumBy(parkTownRows, "yakCount") || 1 const parkTownBusinessRows = parkTownRows.map((item) => ({ ...item, + value: item.protectedAreaWanMu, + unit: "万亩", + segments: { + core: item.coreProtectionAreaWanMu, + general: item.generalControlAreaWanMu, + }, householdCount: Math.round((parkSummary.householdCount * Number(item.livestockCount || item.yakCount || 0)) / parkTownLivestockTotal), edibleGrasslandWanTon: round( (Number(item.grasslandAreaWanMu || 0) / Math.max(parkSummary.grasslandAreaWanMu, 1)) * parkEdibleGrasslandWanTon, @@ -374,6 +383,25 @@ const parkHouseholdDelta = parkSummary.householdCount - sumBy(parkTownBusinessRo if (parkHouseholdDelta && parkTownBusinessRows[0]) { parkTownBusinessRows[0].householdCount += parkHouseholdDelta } +parkTownBusinessRows.forEach((item) => { + const bearingCapacity = round(Number(item.grasslandAreaWanMu || 0) * 10000 * parkGrasslandCapacityPerMu, 0) + const livestockCapacity = Number(item.livestockCount || item.yakCount || 0) + item.bearingCapacity = bearingCapacity + item.livestockCapacity = livestockCapacity + item.overloadRate = bearingCapacity ? round(((livestockCapacity - bearingCapacity) / bearingCapacity) * 100, 1) : 0 + item.searchText = [ + item.name, + `划定面积${item.protectedAreaWanMu}万亩`, + `牦牛${item.yakCount}头`, + `牧户${item.householdCount}户`, + `超载率${item.overloadRate}%`, + ].join(" ") +}) +const parkBalanceBearingCapacity = sumBy(parkTownBusinessRows, "bearingCapacity") +const parkBalanceLivestockCapacity = sumBy(parkTownBusinessRows, "livestockCapacity") +parkSummary.balanceOverloadRate = parkBalanceBearingCapacity + ? round(((parkBalanceLivestockCapacity - parkBalanceBearingCapacity) / parkBalanceBearingCapacity) * 100, 1) + : 0 const withParkPercentExtra = (rows, total = sumBy(rows, "value")) => rows.map((item) => ({ @@ -438,11 +466,74 @@ const parkWoodlandVillageRows = buildParkVillageRows({ weightKey: "woodland", }) +const parkTownDisplayOrder = ["瓦切镇", "麦洼乡", "色地镇"] +const sortParkTownRows = (rows) => parkTownDisplayOrder + .map((name) => rows.find((item) => item.name === name)) + .filter(Boolean) + +const parkTownAreaRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({ + key: `${item.name}-area`, + name: item.name, + value: item.protectedAreaWanMu, + unit: "万亩", + extra: `核心 ${item.coreProtectionAreaWanMu} / 一般 ${item.generalControlAreaWanMu}`, +})) + +const parkTownZoningRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({ + key: `${item.name}-zoning`, + name: item.name, + value: item.protectedAreaWanMu, + unit: "万亩", + segments: { + core: item.coreProtectionAreaWanMu, + general: item.generalControlAreaWanMu, + }, +})) + +const parkTownYakRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({ + key: `${item.name}-yak`, + name: item.name, + value: item.yakCount, + unit: "头", + extra: `${round((item.yakCount / Math.max(parkSummary.yakCount, 1)) * 100, 1)}%`, +})) + +const parkTownBalanceRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({ + key: `${item.name}-balance`, + name: item.name, + value: item.overloadRate, + unit: "%", + extra: `载畜 ${item.livestockCapacity}头`, +})) + +const parkTownEcologyJobRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({ + key: `${item.name}-job`, + name: item.name, + value: item.ecologyJobs, + unit: "个", + extra: `牧户 ${item.householdCount}户`, +})) + +const parkTownLedgerRows = sortParkTownRows(parkTownBusinessRows).map((item) => ({ + ...item, + key: `${item.name}-ledger`, + name: item.name, + value: item.protectedAreaWanMu, + unit: "万亩", + canLocate: true, +})) + export const parkTopStats = [ - { zh: "国家公园面积", value: parkSummary.totalAreaWanMu, unit: "万亩", decimals: 2 }, + { zh: "国家公园总面积", value: parkSummary.nationalParkTotalAreaWanMu, unit: "万亩", decimals: 2 }, + { zh: "红原县划定面积", value: parkSummary.hongyuanDelimitedAreaWanMu, unit: "万亩", decimals: 2 }, + { zh: "牦牛数量", value: parkSummary.yakCount, unit: "头", decimals: 0 }, + { zh: "牧户数量", value: parkSummary.householdCount, unit: "户", decimals: 0 }, + { zh: "草畜平衡超载率", value: parkSummary.balanceOverloadRate, unit: "%", decimals: 1 }, + { zh: "瓦切镇面积", value: parkTownBusinessRows.find((item) => item.name === "瓦切镇")?.protectedAreaWanMu || 0, unit: "万亩", decimals: 2 }, + { zh: "麦洼乡面积", value: parkTownBusinessRows.find((item) => item.name === "麦洼乡")?.protectedAreaWanMu || 0, unit: "万亩", decimals: 2 }, + { zh: "色地镇面积", value: parkTownBusinessRows.find((item) => item.name === "色地镇")?.protectedAreaWanMu || 0, unit: "万亩", decimals: 2 }, { zh: "草地面积", value: parkSummary.grasslandAreaWanMu, unit: "万亩", decimals: 2 }, { zh: "湿地面积", value: parkSummary.wetlandAreaWanMu, unit: "万亩", decimals: 2 }, - { zh: "牦牛数量", value: parkSummary.yakCount, unit: "头", decimals: 0 }, ] export const restorationTopStats = [ @@ -474,56 +565,64 @@ export function getEcologicalTopStats(viewKey) { export const parkPanels = { left: [ { - key: "park-resource-scope", - title: "显示范围资源占比", - type: "donut", - rows: parkResourceScopeRows, + key: "park-town-area", + title: "乡镇划定面积", + type: "column", + variant: "plain", + rows: parkTownAreaRows, }, { - key: "park-wetland-village", - title: "湿地分布村级对比", - type: "line", - rows: parkWetlandVillageRows.slice(0, 8), + key: "park-town-zoning", + title: "乡镇管控区构成", + type: "stackColumn", + segments: [ + { key: "core", name: "核心保护区" }, + { key: "general", name: "一般控制区" }, + ], + rows: parkTownZoningRows, }, { - key: "park-woodland-village", - title: "林地分布村级排行", - type: "heat", - rows: parkWoodlandVillageRows.slice(0, 8), + key: "park-town-yak", + title: "乡镇牦牛数量", + type: "bar", + variant: "raceBar", + rows: parkTownYakRows, }, { - key: "park-grassland-village", - title: "草地分布村级排行", - type: "peak", - rows: parkGrasslandVillageRows.slice(0, 8), + key: "park-town-balance", + title: "草畜平衡超载率", + type: "heat", + rows: parkTownBalanceRows, }, ], right: [ { - key: "park-household-village", - title: "牧户分布村级排行", - type: "column", - variant: "pin", - rows: parkHouseholdVillageRows.slice(0, 8), - }, - { - key: "park-yak-village", - title: "牦牛分布村级排行", - type: "bar", - variant: "distribution", - rows: parkYakVillageRows.slice(0, 8), + key: "park-zoning-structure", + title: "管控区面积结构", + type: "donut", + rows: withParkPercentExtra(parkZoningRows), }, { - key: "park-edible-grass-village", - title: "可食草地资源分布", - type: "split", - rows: parkEdibleGrassVillageRows.slice(0, 8), + key: "park-town-ecology-jobs", + title: "生态管护岗位", + type: "peak", + rows: parkTownEcologyJobRows, }, { - key: "park-zoning-ledger", - title: "核心区和一般区分区面积", - type: "structure", - rows: withParkPercentExtra(parkZoningRows), + key: "park-town-ledger", + title: "乡镇保护管护清单", + type: "projectTable", + span: 2, + searchPlaceholder: "搜索乡镇", + searchKeys: ["name", "searchText"], + columns: [ + { key: "name", label: "乡镇", field: "name", width: "18%", className: "business-project-col-name" }, + { key: "area", label: "面积", field: "protectedAreaWanMu", format: "number", unit: "万亩", width: "22%", align: "right", strong: true, className: "business-project-col-scale" }, + { key: "yak", label: "牦牛", field: "yakCount", format: "number", unit: "头", width: "21%", align: "right" }, + { key: "household", label: "牧户", field: "householdCount", format: "number", unit: "户", width: "18%", align: "right" }, + { key: "rate", label: "超载率", field: "overloadRate", format: "number", unit: "%", width: "21%", align: "right", strong: true }, + ], + rows: parkTownLedgerRows, }, ], } @@ -739,11 +838,17 @@ export function getEcologicalMapRanking(viewKey) { } return { status: "success", - title: "国家公园覆盖范围", + title: "国家公园乡镇划定面积", scope: "town", hideTownNames: false, - metricLabel: "", - unit: "", - rows: [], + onlyRankedTownLabels: true, + metricLabel: "划定面积", + unit: "万亩", + rows: parkTownAreaRows.map((item) => ({ + name: item.name, + value: item.value, + unit: item.unit, + metricLabel: "划定面积", + })), } } diff --git a/src/views/ecologicalProtection/index.vue b/src/views/ecologicalProtection/index.vue index 1983c01..c123900 100644 --- a/src/views/ecologicalProtection/index.vue +++ b/src/views/ecologicalProtection/index.vue @@ -55,14 +55,21 @@ @focus-project="handleAnalysisProjectFocus" /> -
+
@@ -165,6 +172,7 @@ let mapLoadTimer = null const currentView = computed(() => ["national-park", "restoration", "engineering-analysis"].includes(props.activeView) ? props.activeView : "national-park" ) +const isNationalPark = computed(() => currentView.value === "national-park") const isEngineeringAnalysis = computed(() => currentView.value === "engineering-analysis") const baseActiveLayer = computed(() => activeLayerKey.value ? getLayerByKey(currentView.value, activeLayerKey.value) : null) const selectedLegend = computed(() => @@ -191,6 +199,8 @@ const rightPanels = computed(() => currentPanels.value.right || []) const panelHeight = 190 const panelGap = 12 const doublePanelHeight = panelHeight * 2 + panelGap +const layerPanelWidth = computed(() => isNationalPark.value ? 178 : 248) +const layerPanelHeight = computed(() => isNationalPark.value ? 118 : 430) const featurePanelVisible = computed(() => Boolean(featurePanelPoint.value)) const featurePanelHeight = computed(() => { if (featureLoading.value || featureEmpty.value || !selectedFeature.value) return 186 @@ -386,6 +396,15 @@ function handleLayerStatus(status) { } function handlePanelRowSelect(panel, row) { + if (currentView.value === "national-park" && row?.name) { + closeFeaturePanel() + mapSceneRef.value?.focusTown?.(row.name, { + distanceScale: 0.66, + targetLocalZ: 0.84, + cameraOffset: { x: 0, y: 1.05, z: 0 }, + }) + return + } if (currentView.value !== "restoration" || panel?.key !== "restoration-projects") return const projectName = row?.projectName || row?.name if (!projectName) return @@ -482,6 +501,24 @@ function hideLoading() { transform: scale(0.92); transform-origin: right top; } + + &.is-national-park { + top: auto; + left: auto; + right: 452px; + bottom: 144px; + width: 178px; + height: 118px; + opacity: 0.92; + + .layer-panel-legend { + min-height: 0; + } + + .layer-panel-legend-row { + height: 28px; + } + } } .ecological-bottom-topic { diff --git a/src/views/forestGrassWet/components/GrasslandNdviTimeline.vue b/src/views/forestGrassWet/components/GrasslandNdviTimeline.vue new file mode 100644 index 0000000..954640d --- /dev/null +++ b/src/views/forestGrassWet/components/GrasslandNdviTimeline.vue @@ -0,0 +1,323 @@ + + + + + diff --git a/src/views/forestGrassWet/components/LayerPanel.vue b/src/views/forestGrassWet/components/LayerPanel.vue index dd516a3..eac5484 100644 --- a/src/views/forestGrassWet/components/LayerPanel.vue +++ b/src/views/forestGrassWet/components/LayerPanel.vue @@ -1,7 +1,7 @@