parent
0a47026d7e
commit
4f02a8d41c
@ -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 |
||||||
|
} |
||||||
@ -0,0 +1,894 @@ |
|||||||
|
<template> |
||||||
|
<div class="chart-guide-screen"> |
||||||
|
<div class="guide-bg guide-bg-grid"></div> |
||||||
|
<div class="guide-bg guide-bg-glow"></div> |
||||||
|
|
||||||
|
<header class="guide-header"> |
||||||
|
<div> |
||||||
|
<div class="guide-kicker">Chart Style System</div> |
||||||
|
<h1>图表样式规范</h1> |
||||||
|
</div> |
||||||
|
<div class="guide-palette" aria-label="系统图表色板"> |
||||||
|
<span |
||||||
|
v-for="color in paletteItems" |
||||||
|
:key="color" |
||||||
|
:style="{ background: color }" |
||||||
|
></span> |
||||||
|
</div> |
||||||
|
</header> |
||||||
|
|
||||||
|
<main class="guide-layout"> |
||||||
|
<section class="guide-chart-grid" aria-label="系统图表示例"> |
||||||
|
<m-card |
||||||
|
v-for="(card, index) in chartCards" |
||||||
|
:key="card.key" |
||||||
|
class="guide-chart-card" |
||||||
|
:class="`is-${card.size || 'standard'}`" |
||||||
|
:width="getCardSize(card).width" |
||||||
|
:height="getCardSize(card).height" |
||||||
|
:title="`${formatChartNo(index)} ${card.title}`" |
||||||
|
:data-unit="card.unit" |
||||||
|
> |
||||||
|
<div class="guide-card-content"> |
||||||
|
<v-chart class="guide-chart" :option="card.option" autoresize /> |
||||||
|
</div> |
||||||
|
</m-card> |
||||||
|
</section> |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<script setup> |
||||||
|
import VChart from "vue-echarts" |
||||||
|
import mCard from "@/components/mCard/index.vue" |
||||||
|
import { |
||||||
|
categoryAxis, |
||||||
|
chartGrid, |
||||||
|
chartLegend, |
||||||
|
chartPalette, |
||||||
|
chartTooltip, |
||||||
|
createSystemBarOption, |
||||||
|
createSystemGroupedBarOption, |
||||||
|
createSystemLineOption, |
||||||
|
createSystemPieOption, |
||||||
|
createSystemRadarOption, |
||||||
|
createSystemStackedBarOption, |
||||||
|
formatChartValue, |
||||||
|
valueAxis, |
||||||
|
verticalGradient, |
||||||
|
withChartDefaults, |
||||||
|
} from "@/utils/chartTheme" |
||||||
|
|
||||||
|
const paletteItems = chartPalette |
||||||
|
const cardSizes = { |
||||||
|
standard: { width: 398, height: 190, label: "398x190" }, |
||||||
|
tall: { width: 398, height: 392, label: "398x392" }, |
||||||
|
} |
||||||
|
|
||||||
|
const townCategories = ["瓦切镇", "安曲镇", "邛溪镇", "龙日镇", "刷经寺镇", "麦洼乡", "查尔玛乡"] |
||||||
|
const yakRows = [ |
||||||
|
{ name: "瓦切镇", value: 74721 }, |
||||||
|
{ name: "安曲镇", value: 65780 }, |
||||||
|
{ name: "邛溪镇", value: 63466 }, |
||||||
|
{ name: "龙日镇", value: 54691 }, |
||||||
|
{ name: "刷经寺镇", value: 45940 }, |
||||||
|
{ name: "麦洼乡", value: 38202 }, |
||||||
|
{ name: "查尔玛乡", value: 31766 }, |
||||||
|
] |
||||||
|
|
||||||
|
const resourceCategories = ["草地", "湿地", "林地", "牧场", "保护地"] |
||||||
|
const months = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月"] |
||||||
|
|
||||||
|
const chartCards = [ |
||||||
|
{ |
||||||
|
key: "bar-vertical", |
||||||
|
type: "柱状图", |
||||||
|
title: "林草湿资源面积", |
||||||
|
unit: "单位:万亩", |
||||||
|
option: createSystemBarOption({ |
||||||
|
categories: resourceCategories, |
||||||
|
rows: [ |
||||||
|
{ name: "草地", value: 486.2 }, |
||||||
|
{ name: "湿地", value: 82.4 }, |
||||||
|
{ name: "林地", value: 126.8 }, |
||||||
|
{ name: "牧场", value: 58.6 }, |
||||||
|
{ name: "保护地", value: 214.5 }, |
||||||
|
], |
||||||
|
unit: "万亩", |
||||||
|
name: "资源面积", |
||||||
|
showLabel: false, |
||||||
|
}), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "bar-horizontal", |
||||||
|
type: "横向柱状图", |
||||||
|
title: "乡镇牦牛存栏排行", |
||||||
|
unit: "单位:头", |
||||||
|
option: createSystemBarOption({ |
||||||
|
categories: townCategories, |
||||||
|
rows: yakRows, |
||||||
|
unit: "头", |
||||||
|
name: "牦牛存栏", |
||||||
|
orientation: "horizontal", |
||||||
|
colorIndex: 1, |
||||||
|
}), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "grouped-bar", |
||||||
|
type: "分组柱状图", |
||||||
|
title: "林草湿年度对比", |
||||||
|
unit: "单位:万亩", |
||||||
|
option: createSystemGroupedBarOption({ |
||||||
|
categories: ["2021", "2022", "2023", "2024", "2025"], |
||||||
|
unit: "万亩", |
||||||
|
series: [ |
||||||
|
{ name: "草地", values: [472, 476, 481, 486, 489] }, |
||||||
|
{ name: "湿地", values: [76, 79, 81, 82, 84] }, |
||||||
|
{ name: "林地", values: [119, 121, 124, 127, 130] }, |
||||||
|
], |
||||||
|
}), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "stacked-bar", |
||||||
|
type: "堆叠柱状图", |
||||||
|
title: "草地利用结构", |
||||||
|
unit: "单位:万亩", |
||||||
|
option: createSystemStackedBarOption({ |
||||||
|
categories: ["瓦切", "安曲", "邛溪", "龙日", "刷经寺"], |
||||||
|
unit: "万亩", |
||||||
|
series: [ |
||||||
|
{ name: "可利用草地", values: [66, 52, 48, 44, 38] }, |
||||||
|
{ name: "禁牧草地", values: [18, 21, 16, 13, 12] }, |
||||||
|
{ name: "退化治理", values: [9, 7, 8, 6, 5] }, |
||||||
|
], |
||||||
|
}), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "line-trend", |
||||||
|
type: "折线图", |
||||||
|
title: "牦牛存栏趋势", |
||||||
|
unit: "单位:万头", |
||||||
|
option: createSystemLineOption({ |
||||||
|
categories: months, |
||||||
|
unit: "万头", |
||||||
|
series: [ |
||||||
|
{ name: "2025年", values: [45.2, 45.8, 46.4, 47.1, 48.3, 49.1, 49.8, 50.2] }, |
||||||
|
{ name: "2024年", values: [42.9, 43.5, 44.2, 44.8, 45.9, 46.5, 47.1, 47.7] }, |
||||||
|
], |
||||||
|
}), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "area-line", |
||||||
|
type: "面积折线图", |
||||||
|
title: "草地产草量走势", |
||||||
|
unit: "单位:万吨", |
||||||
|
option: createSystemLineOption({ |
||||||
|
categories: months, |
||||||
|
unit: "万吨", |
||||||
|
area: true, |
||||||
|
series: [ |
||||||
|
{ name: "鲜草产量", values: [12.4, 14.1, 19.8, 28.6, 36.2, 43.5, 46.8, 41.2] }, |
||||||
|
], |
||||||
|
}), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "donut", |
||||||
|
type: "环图", |
||||||
|
title: "湿地类型结构", |
||||||
|
unit: "单位:%", |
||||||
|
option: createSystemPieOption({ |
||||||
|
unit: "万亩", |
||||||
|
name: "湿地结构", |
||||||
|
rows: [ |
||||||
|
{ name: "沼泽湿地", value: 36.8 }, |
||||||
|
{ name: "河流湿地", value: 21.3 }, |
||||||
|
{ name: "湖泊湿地", value: 14.6 }, |
||||||
|
{ name: "人工湿地", value: 9.7 }, |
||||||
|
], |
||||||
|
}), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "rose", |
||||||
|
type: "玫瑰图", |
||||||
|
title: "林地类型占比", |
||||||
|
unit: "单位:万亩", |
||||||
|
option: createSystemPieOption({ |
||||||
|
unit: "万亩", |
||||||
|
name: "林地结构", |
||||||
|
rose: true, |
||||||
|
donut: false, |
||||||
|
rows: [ |
||||||
|
{ name: "乔木林地", value: 42.6 }, |
||||||
|
{ name: "灌木林地", value: 35.2 }, |
||||||
|
{ name: "疏林地", value: 18.4 }, |
||||||
|
{ name: "未成林地", value: 8.7 }, |
||||||
|
], |
||||||
|
}), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "radar", |
||||||
|
type: "雷达图", |
||||||
|
title: "生态资源综合评价", |
||||||
|
unit: "单位:分", |
||||||
|
size: "tall", |
||||||
|
option: createSystemRadarOption({ |
||||||
|
indicators: [ |
||||||
|
{ name: "覆盖度", max: 100 }, |
||||||
|
{ name: "稳定性", max: 100 }, |
||||||
|
{ name: "承载力", max: 100 }, |
||||||
|
{ name: "保护等级", max: 100 }, |
||||||
|
{ name: "治理成效", max: 100 }, |
||||||
|
], |
||||||
|
rows: [ |
||||||
|
{ name: "当前值", values: [86, 78, 72, 91, 83] }, |
||||||
|
{ name: "目标值", values: [92, 86, 84, 95, 90] }, |
||||||
|
], |
||||||
|
}), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "dual-axis", |
||||||
|
type: "柱线组合", |
||||||
|
title: "草畜平衡压力趋势", |
||||||
|
unit: "左:万头 / 右:%", |
||||||
|
size: "tall", |
||||||
|
option: createDualAxisOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "gauge", |
||||||
|
type: "仪表盘", |
||||||
|
title: "草畜平衡预警指数", |
||||||
|
unit: "单位:分", |
||||||
|
option: createGaugeOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "scatter", |
||||||
|
type: "散点气泡图", |
||||||
|
title: "牧户规模与识别风险", |
||||||
|
unit: "X:头 / Y:%", |
||||||
|
option: createScatterOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "heatmap", |
||||||
|
type: "热力图", |
||||||
|
title: "巡护事件时段热力", |
||||||
|
unit: "单位:件", |
||||||
|
option: createHeatmapOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "funnel", |
||||||
|
type: "漏斗图", |
||||||
|
title: "生态问题处置转化", |
||||||
|
unit: "单位:件", |
||||||
|
option: createFunnelOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "progress-ring", |
||||||
|
type: "进度环", |
||||||
|
title: "治理任务完成率", |
||||||
|
unit: "单位:%", |
||||||
|
option: createProgressRingOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "polar-bar", |
||||||
|
type: "极坐标柱图", |
||||||
|
title: "月度遥感识别强度", |
||||||
|
unit: "单位:次", |
||||||
|
option: createPolarBarOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "pictorial", |
||||||
|
type: "象形柱图", |
||||||
|
title: "重点乡镇牦牛识别", |
||||||
|
unit: "单位:千头", |
||||||
|
option: createPictorialBarOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "candlestick", |
||||||
|
type: "K线图", |
||||||
|
title: "牦牛交易价格波动", |
||||||
|
unit: "单位:元/公斤", |
||||||
|
size: "tall", |
||||||
|
option: createCandlestickOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "boxplot", |
||||||
|
type: "箱线图", |
||||||
|
title: "草地产草量区间", |
||||||
|
unit: "单位:公斤/亩", |
||||||
|
size: "tall", |
||||||
|
option: createBoxplotOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "waterfall", |
||||||
|
type: "瀑布图", |
||||||
|
title: "草地恢复面积贡献", |
||||||
|
unit: "单位:万亩", |
||||||
|
option: createWaterfallOption(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "nested-pie", |
||||||
|
type: "嵌套环图", |
||||||
|
title: "林地保护类型结构", |
||||||
|
unit: "单位:万亩", |
||||||
|
size: "tall", |
||||||
|
option: createNestedPieOption(), |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
function formatChartNo(index) { |
||||||
|
return `C${String(index + 1).padStart(2, "0")}` |
||||||
|
} |
||||||
|
|
||||||
|
function getCardSize(card) { |
||||||
|
return cardSizes[card.size || "standard"] || cardSizes.standard |
||||||
|
} |
||||||
|
|
||||||
|
function createDualAxisOption() { |
||||||
|
const categories = ["1月", "2月", "3月", "4月", "5月", "6月"] |
||||||
|
return withChartDefaults({ |
||||||
|
color: chartPalette, |
||||||
|
grid: chartGrid({ left: 48, right: 48, top: 42, bottom: 38 }), |
||||||
|
tooltip: chartTooltip((params) => axisRows(params), { trigger: "axis", axisPointer: { type: "shadow" } }), |
||||||
|
legend: chartLegend({ top: 0, right: 4 }), |
||||||
|
xAxis: categoryAxis(categories), |
||||||
|
yAxis: [ |
||||||
|
valueAxis({ axisLabel: { formatter: "{value}" } }), |
||||||
|
valueAxis({ |
||||||
|
axisLabel: { formatter: "{value}%" }, |
||||||
|
splitLine: { show: false }, |
||||||
|
}), |
||||||
|
], |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
name: "理论载畜量", |
||||||
|
type: "bar", |
||||||
|
barWidth: 18, |
||||||
|
data: [42.5, 43.2, 45.8, 48.1, 49.6, 51.2], |
||||||
|
itemStyle: { borderRadius: [7, 7, 0, 0], color: verticalGradient(chartPalette[0]) }, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "压力指数", |
||||||
|
type: "line", |
||||||
|
yAxisIndex: 1, |
||||||
|
smooth: true, |
||||||
|
symbolSize: 7, |
||||||
|
data: [72, 75, 79, 83, 86, 88], |
||||||
|
lineStyle: { width: 3, color: chartPalette[2], shadowColor: "rgba(255, 224, 107, .45)", shadowBlur: 10 }, |
||||||
|
itemStyle: { color: "#041522", borderColor: chartPalette[2], borderWidth: 2 }, |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createGaugeOption() { |
||||||
|
return withChartDefaults({ |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
type: "gauge", |
||||||
|
startAngle: 210, |
||||||
|
endAngle: -30, |
||||||
|
min: 0, |
||||||
|
max: 100, |
||||||
|
radius: "88%", |
||||||
|
center: ["50%", "58%"], |
||||||
|
progress: { |
||||||
|
show: true, |
||||||
|
width: 14, |
||||||
|
itemStyle: { color: chartPalette[2] }, |
||||||
|
}, |
||||||
|
axisLine: { |
||||||
|
lineStyle: { |
||||||
|
width: 14, |
||||||
|
color: [[1, "rgba(48, 220, 255, 0.14)"]], |
||||||
|
}, |
||||||
|
}, |
||||||
|
axisTick: { show: false }, |
||||||
|
splitLine: { length: 8, lineStyle: { color: "rgba(225, 249, 255, 0.42)", width: 1 } }, |
||||||
|
axisLabel: { distance: 18, color: "rgba(225, 249, 255, 0.56)", fontSize: 10 }, |
||||||
|
pointer: { width: 4, length: "58%", itemStyle: { color: chartPalette[2] } }, |
||||||
|
anchor: { show: true, size: 7, itemStyle: { color: chartPalette[2] } }, |
||||||
|
title: { offsetCenter: [0, "52%"], color: "rgba(225, 249, 255, 0.68)", fontSize: 12 }, |
||||||
|
detail: { |
||||||
|
valueAnimation: true, |
||||||
|
offsetCenter: [0, "28%"], |
||||||
|
color: "#ffe06b", |
||||||
|
fontSize: 28, |
||||||
|
fontWeight: 700, |
||||||
|
formatter: "{value}", |
||||||
|
}, |
||||||
|
data: [{ value: 78, name: "中度预警" }], |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createScatterOption() { |
||||||
|
const rows = [ |
||||||
|
[280, 12, 18, "牧户A"], |
||||||
|
[420, 18, 24, "牧户B"], |
||||||
|
[510, 32, 42, "牧户C"], |
||||||
|
[760, 26, 35, "牧户D"], |
||||||
|
[980, 44, 58, "牧户E"], |
||||||
|
[1210, 38, 52, "牧户F"], |
||||||
|
[1460, 62, 75, "牧户G"], |
||||||
|
] |
||||||
|
return withChartDefaults({ |
||||||
|
grid: chartGrid({ left: 48, right: 26, top: 28, bottom: 38 }), |
||||||
|
tooltip: chartTooltip((params) => { |
||||||
|
const [count, risk, times, name] = params.value |
||||||
|
return `${params.marker}${name}<br/>存栏:${formatChartValue(count)}头<br/>风险:${risk}%<br/>识别:${times}次` |
||||||
|
}), |
||||||
|
xAxis: valueAxis({ name: "存栏", nameTextStyle: { color: "rgba(225, 249, 255, .56)" } }), |
||||||
|
yAxis: valueAxis({ name: "风险", axisLabel: { formatter: "{value}%" }, nameTextStyle: { color: "rgba(225, 249, 255, .56)" } }), |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
name: "牧户", |
||||||
|
type: "scatter", |
||||||
|
symbolSize: (value) => Math.max(12, Math.min(42, value[2] / 2)), |
||||||
|
data: rows, |
||||||
|
itemStyle: { |
||||||
|
color: "rgba(50, 220, 255, 0.72)", |
||||||
|
borderColor: "#24f6c4", |
||||||
|
borderWidth: 1, |
||||||
|
shadowColor: "rgba(36, 246, 196, .35)", |
||||||
|
shadowBlur: 12, |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createHeatmapOption() { |
||||||
|
const days = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] |
||||||
|
const periods = ["凌晨", "上午", "中午", "下午", "夜间"] |
||||||
|
const data = periods.flatMap((period, y) => days.map((day, x) => [x, y, [4, 8, 11, 15, 20][y] + x * 2 + (x === 4 ? 8 : 0)])) |
||||||
|
return withChartDefaults({ |
||||||
|
grid: chartGrid({ left: 50, right: 20, top: 34, bottom: 36 }), |
||||||
|
tooltip: chartTooltip((params) => `${days[params.value[0]]} ${periods[params.value[1]]}<br/>事件:${params.value[2]}件`), |
||||||
|
xAxis: categoryAxis(days), |
||||||
|
yAxis: categoryAxis(periods), |
||||||
|
visualMap: { |
||||||
|
min: 0, |
||||||
|
max: 40, |
||||||
|
show: false, |
||||||
|
inRange: { color: ["rgba(48, 220, 255, .12)", "rgba(36, 246, 196, .72)", "#ffe06b"] }, |
||||||
|
}, |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
type: "heatmap", |
||||||
|
data, |
||||||
|
label: { show: true, color: "#e7fbff", fontSize: 10 }, |
||||||
|
itemStyle: { borderColor: "rgba(3, 18, 31, .86)", borderWidth: 2 }, |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createFunnelOption() { |
||||||
|
return withChartDefaults({ |
||||||
|
color: chartPalette, |
||||||
|
tooltip: chartTooltip((params) => `${params.marker}${params.name}:${formatChartValue(params.value)}件`), |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
type: "funnel", |
||||||
|
left: "9%", |
||||||
|
top: 18, |
||||||
|
width: "82%", |
||||||
|
height: "78%", |
||||||
|
minSize: "32%", |
||||||
|
maxSize: "100%", |
||||||
|
sort: "descending", |
||||||
|
gap: 4, |
||||||
|
label: { color: "#e7fbff", fontSize: 11, formatter: "{b} {c}" }, |
||||||
|
labelLine: { lineStyle: { color: "rgba(225, 249, 255, .34)" } }, |
||||||
|
itemStyle: { borderColor: "rgba(3, 18, 31, .9)", borderWidth: 2 }, |
||||||
|
data: [ |
||||||
|
{ name: "问题发现", value: 168 }, |
||||||
|
{ name: "派单核查", value: 142 }, |
||||||
|
{ name: "处置整改", value: 118 }, |
||||||
|
{ name: "复核通过", value: 96 }, |
||||||
|
{ name: "闭环归档", value: 84 }, |
||||||
|
], |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createProgressRingOption() { |
||||||
|
return withChartDefaults({ |
||||||
|
tooltip: chartTooltip((params) => `${params.marker}${params.name}:${params.value}%`), |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
type: "pie", |
||||||
|
radius: ["62%", "76%"], |
||||||
|
center: ["50%", "52%"], |
||||||
|
silent: true, |
||||||
|
label: { show: false }, |
||||||
|
data: [ |
||||||
|
{ name: "已完成", value: 72, itemStyle: { color: chartPalette[0] } }, |
||||||
|
{ name: "未完成", value: 28, itemStyle: { color: "rgba(48, 220, 255, .12)" } }, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "pie", |
||||||
|
radius: ["42%", "46%"], |
||||||
|
center: ["50%", "52%"], |
||||||
|
silent: true, |
||||||
|
label: { |
||||||
|
position: "center", |
||||||
|
formatter: "72%\n完成", |
||||||
|
color: "#ffe06b", |
||||||
|
fontSize: 22, |
||||||
|
lineHeight: 30, |
||||||
|
fontWeight: 700, |
||||||
|
}, |
||||||
|
data: [{ value: 1, itemStyle: { color: "rgba(255, 224, 107, .28)" } }], |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createPolarBarOption() { |
||||||
|
const monthsShort = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] |
||||||
|
return withChartDefaults({ |
||||||
|
angleAxis: { |
||||||
|
type: "category", |
||||||
|
data: monthsShort, |
||||||
|
axisLabel: { color: "rgba(225, 249, 255, .62)", fontSize: 10 }, |
||||||
|
axisLine: { lineStyle: { color: "rgba(48, 220, 255, .22)" } }, |
||||||
|
axisTick: { show: false }, |
||||||
|
}, |
||||||
|
radiusAxis: { |
||||||
|
axisLabel: { color: "rgba(225, 249, 255, .44)", fontSize: 10 }, |
||||||
|
splitLine: { lineStyle: { color: "rgba(48, 220, 255, .1)" } }, |
||||||
|
axisLine: { show: false }, |
||||||
|
}, |
||||||
|
polar: { radius: "68%", center: ["50%", "54%"] }, |
||||||
|
tooltip: chartTooltip((params) => `${params.name}月:${formatChartValue(params.value)}次`), |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
type: "bar", |
||||||
|
coordinateSystem: "polar", |
||||||
|
data: [18, 22, 26, 34, 46, 58, 64, 61, 45, 32, 24, 19], |
||||||
|
itemStyle: { color: verticalGradient(chartPalette[1]) }, |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createPictorialBarOption() { |
||||||
|
const categories = ["瓦切", "安曲", "邛溪", "刷经寺"] |
||||||
|
const values = [76, 63, 58, 41] |
||||||
|
return withChartDefaults({ |
||||||
|
grid: chartGrid({ left: 42, right: 22, top: 28, bottom: 38 }), |
||||||
|
tooltip: chartTooltip((params) => `${params.name}:${params.value}千头`), |
||||||
|
xAxis: categoryAxis(categories), |
||||||
|
yAxis: valueAxis(), |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
type: "pictorialBar", |
||||||
|
symbol: "roundRect", |
||||||
|
symbolRepeat: true, |
||||||
|
symbolSize: [18, 8], |
||||||
|
symbolMargin: 3, |
||||||
|
data: values, |
||||||
|
itemStyle: { color: chartPalette[0] }, |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createCandlestickOption() { |
||||||
|
const days = ["01", "02", "03", "04", "05", "06", "07", "08", "09"] |
||||||
|
return withChartDefaults({ |
||||||
|
grid: chartGrid({ left: 48, right: 24, top: 28, bottom: 38 }), |
||||||
|
tooltip: chartTooltip((params) => { |
||||||
|
const [open, close, low, high] = params.value |
||||||
|
return `${params.name}日<br/>开盘:${open}<br/>收盘:${close}<br/>最低:${low}<br/>最高:${high}` |
||||||
|
}), |
||||||
|
xAxis: categoryAxis(days), |
||||||
|
yAxis: valueAxis({ scale: true }), |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
type: "candlestick", |
||||||
|
data: [ |
||||||
|
[31.2, 32.4, 30.8, 33.1], |
||||||
|
[32.4, 31.9, 31.2, 33.5], |
||||||
|
[31.9, 33.2, 31.5, 34.1], |
||||||
|
[33.2, 34.0, 32.7, 34.6], |
||||||
|
[34.0, 33.5, 32.9, 34.2], |
||||||
|
[33.5, 35.1, 33.1, 35.8], |
||||||
|
[35.1, 34.6, 33.8, 35.4], |
||||||
|
[34.6, 36.0, 34.1, 36.4], |
||||||
|
[36.0, 35.7, 35.2, 36.8], |
||||||
|
], |
||||||
|
itemStyle: { |
||||||
|
color: chartPalette[0], |
||||||
|
color0: chartPalette[4], |
||||||
|
borderColor: chartPalette[0], |
||||||
|
borderColor0: chartPalette[4], |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createBoxplotOption() { |
||||||
|
return withChartDefaults({ |
||||||
|
grid: chartGrid({ left: 48, right: 24, top: 28, bottom: 38 }), |
||||||
|
tooltip: chartTooltip((params) => `${params.name}<br/>最小:${params.value[1]}<br/>Q1:${params.value[2]}<br/>中位:${params.value[3]}<br/>Q3:${params.value[4]}<br/>最大:${params.value[5]}`), |
||||||
|
xAxis: categoryAxis(["瓦切", "安曲", "邛溪", "龙日"]), |
||||||
|
yAxis: valueAxis(), |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
type: "boxplot", |
||||||
|
data: [ |
||||||
|
[120, 148, 176, 205, 238], |
||||||
|
[96, 126, 158, 184, 220], |
||||||
|
[142, 168, 196, 224, 252], |
||||||
|
[110, 132, 160, 188, 216], |
||||||
|
], |
||||||
|
itemStyle: { |
||||||
|
color: "rgba(36, 246, 196, .18)", |
||||||
|
borderColor: chartPalette[0], |
||||||
|
borderWidth: 2, |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createWaterfallOption() { |
||||||
|
const categories = ["期初", "补播", "围栏", "退化", "灾害", "期末"] |
||||||
|
const assist = [0, 486, 518, 542, 529, 0] |
||||||
|
const values = [486, 32, 24, -13, -8, 521] |
||||||
|
return withChartDefaults({ |
||||||
|
grid: chartGrid({ left: 48, right: 22, top: 28, bottom: 38 }), |
||||||
|
tooltip: chartTooltip((params) => `${params.name}:${formatChartValue(values[params.dataIndex])}万亩`), |
||||||
|
xAxis: categoryAxis(categories), |
||||||
|
yAxis: valueAxis(), |
||||||
|
series: [ |
||||||
|
{ type: "bar", stack: "total", data: assist, itemStyle: { color: "transparent" }, emphasis: { disabled: true } }, |
||||||
|
{ |
||||||
|
type: "bar", |
||||||
|
stack: "total", |
||||||
|
barWidth: 18, |
||||||
|
data: values.map((value) => Math.abs(value)), |
||||||
|
itemStyle: { |
||||||
|
borderRadius: [6, 6, 0, 0], |
||||||
|
color: (params) => (values[params.dataIndex] < 0 ? chartPalette[4] : verticalGradient(chartPalette[0])), |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function createNestedPieOption() { |
||||||
|
return withChartDefaults({ |
||||||
|
color: chartPalette, |
||||||
|
tooltip: chartTooltip((params) => `${params.marker}${params.name}:${formatChartValue(params.value)}万亩`), |
||||||
|
series: [ |
||||||
|
{ |
||||||
|
type: "pie", |
||||||
|
radius: [0, "34%"], |
||||||
|
center: ["50%", "52%"], |
||||||
|
label: { color: "#e7fbff", fontSize: 10, position: "inside" }, |
||||||
|
data: [ |
||||||
|
{ name: "公益林", value: 68 }, |
||||||
|
{ name: "商品林", value: 34 }, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "pie", |
||||||
|
radius: ["46%", "70%"], |
||||||
|
center: ["50%", "52%"], |
||||||
|
label: { color: "#e7fbff", fontSize: 10, formatter: "{b}\n{d}%" }, |
||||||
|
labelLine: { length: 8, length2: 8, lineStyle: { color: "rgba(223, 250, 255, .38)" } }, |
||||||
|
data: [ |
||||||
|
{ name: "天然林", value: 42 }, |
||||||
|
{ name: "人工林", value: 26 }, |
||||||
|
{ name: "用材林", value: 18 }, |
||||||
|
{ name: "经济林", value: 16 }, |
||||||
|
], |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function axisRows(params) { |
||||||
|
const rows = Array.isArray(params) ? params : [params] |
||||||
|
return rows |
||||||
|
.map((item) => `${item.marker}${item.seriesName || item.name}:${formatChartValue(item.value)}${item.seriesType === "line" ? "%" : ""}`) |
||||||
|
.join("<br/>") |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<style scoped lang="scss"> |
||||||
|
.chart-guide-screen { |
||||||
|
position: relative; |
||||||
|
height: 100vh; |
||||||
|
padding: 32px 36px 42px; |
||||||
|
overflow-x: hidden; |
||||||
|
overflow-y: auto; |
||||||
|
overscroll-behavior: contain; |
||||||
|
color: #e7fbff; |
||||||
|
background: |
||||||
|
radial-gradient(circle at 50% 18%, rgba(36, 246, 196, 0.12), transparent 31%), |
||||||
|
linear-gradient(180deg, #04101d 0%, #020914 46%, #010611 100%); |
||||||
|
font-family: AlibabaPuHuiTi, "Microsoft YaHei", Arial, sans-serif; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-bg { |
||||||
|
position: fixed; |
||||||
|
pointer-events: none; |
||||||
|
inset: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-bg-grid { |
||||||
|
opacity: 0.48; |
||||||
|
background-image: |
||||||
|
linear-gradient(rgba(48, 220, 255, 0.08) 1px, transparent 1px), |
||||||
|
linear-gradient(90deg, rgba(48, 220, 255, 0.08) 1px, transparent 1px); |
||||||
|
background-size: 58px 58px; |
||||||
|
mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.72) 58%, transparent 100%); |
||||||
|
} |
||||||
|
|
||||||
|
.guide-bg-glow { |
||||||
|
opacity: 0.68; |
||||||
|
background: |
||||||
|
radial-gradient(circle at 15% 18%, rgba(50, 220, 255, 0.13), transparent 24%), |
||||||
|
radial-gradient(circle at 82% 28%, rgba(255, 224, 107, 0.09), transparent 22%); |
||||||
|
} |
||||||
|
|
||||||
|
.guide-header, |
||||||
|
.guide-layout { |
||||||
|
position: relative; |
||||||
|
z-index: 1; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-header { |
||||||
|
display: flex; |
||||||
|
align-items: flex-end; |
||||||
|
justify-content: space-between; |
||||||
|
gap: 28px; |
||||||
|
margin-bottom: 14px; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-kicker { |
||||||
|
margin-bottom: 8px; |
||||||
|
color: #24f6c4; |
||||||
|
font-size: 12px; |
||||||
|
line-height: 1; |
||||||
|
letter-spacing: 0; |
||||||
|
text-transform: uppercase; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-header h1 { |
||||||
|
margin: 0; |
||||||
|
color: #f4fdff; |
||||||
|
font-size: 32px; |
||||||
|
line-height: 1.08; |
||||||
|
font-weight: 700; |
||||||
|
text-shadow: 0 0 18px rgba(48, 220, 255, 0.3); |
||||||
|
} |
||||||
|
|
||||||
|
.guide-palette { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(6, 34px); |
||||||
|
gap: 8px; |
||||||
|
padding: 10px; |
||||||
|
border: 1px solid rgba(48, 220, 255, 0.26); |
||||||
|
background: rgba(3, 26, 42, 0.72); |
||||||
|
} |
||||||
|
|
||||||
|
.guide-palette span { |
||||||
|
display: block; |
||||||
|
width: 34px; |
||||||
|
height: 18px; |
||||||
|
box-shadow: 0 0 14px currentColor; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-layout { |
||||||
|
display: block; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-chart-grid { |
||||||
|
display: flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
align-items: flex-start; |
||||||
|
gap: 12px; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-chart-card { |
||||||
|
flex: 0 0 auto; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-chart-card::after { |
||||||
|
content: attr(data-unit); |
||||||
|
position: absolute; |
||||||
|
top: 9px; |
||||||
|
right: 16px; |
||||||
|
z-index: 4; |
||||||
|
max-width: 112px; |
||||||
|
color: rgba(255, 224, 107, 0.92); |
||||||
|
font-size: 12px; |
||||||
|
line-height: 1; |
||||||
|
font-weight: 700; |
||||||
|
text-align: right; |
||||||
|
white-space: nowrap; |
||||||
|
overflow: hidden; |
||||||
|
text-overflow: ellipsis; |
||||||
|
pointer-events: none; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-chart-card :deep(.m-card-hd-title) { |
||||||
|
right: 150px; |
||||||
|
color: #ffffff !important; |
||||||
|
background: none !important; |
||||||
|
-webkit-text-fill-color: #ffffff !important; |
||||||
|
text-shadow: none !important; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-chart-card :deep(.m-card-bd-content) { |
||||||
|
top: 40px; |
||||||
|
bottom: 10px; |
||||||
|
color: #f3feff; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-chart-card :deep(.m-card-bd-bg) { |
||||||
|
filter: brightness(1.08) saturate(1.04); |
||||||
|
} |
||||||
|
|
||||||
|
.guide-chart-card :deep(.m-card-bd-bg::after) { |
||||||
|
content: ""; |
||||||
|
position: absolute; |
||||||
|
inset: 4px 5px 6px; |
||||||
|
z-index: 0; |
||||||
|
pointer-events: none; |
||||||
|
border: 1px solid rgba(48, 220, 255, 0.14); |
||||||
|
background: |
||||||
|
linear-gradient(180deg, rgba(17, 73, 92, 0.16), rgba(6, 29, 44, 0.14)), |
||||||
|
linear-gradient(90deg, rgba(48, 220, 255, 0.1), transparent 42%, rgba(48, 220, 255, 0.06)); |
||||||
|
box-shadow: |
||||||
|
inset 0 0 0 1px rgba(48, 220, 255, 0.06), |
||||||
|
inset 0 12px 28px rgba(48, 220, 255, 0.04); |
||||||
|
} |
||||||
|
|
||||||
|
.guide-card-content { |
||||||
|
position: relative; |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-chart { |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 1180px) { |
||||||
|
.chart-guide-screen { |
||||||
|
padding: 24px 20px 34px; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-header { |
||||||
|
align-items: flex-start; |
||||||
|
flex-direction: column; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 760px) { |
||||||
|
.chart-guide-screen { |
||||||
|
padding: 20px 14px 28px; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-header h1 { |
||||||
|
font-size: 26px; |
||||||
|
} |
||||||
|
|
||||||
|
.guide-palette { |
||||||
|
grid-template-columns: repeat(3, 34px); |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,323 @@ |
|||||||
|
<template> |
||||||
|
<div class="grassland-ndvi-timeline"> |
||||||
|
<div class="grassland-ndvi-timeline-head"> |
||||||
|
<span>草地健康/退化</span> |
||||||
|
<strong>{{ activeItem?.label || statusText }}</strong> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div v-if="loading" class="grassland-ndvi-timeline-state">加载月份数据</div> |
||||||
|
<div v-else-if="!items.length" class="grassland-ndvi-timeline-state">暂无月份数据</div> |
||||||
|
<div v-else class="grassland-ndvi-timeline-body"> |
||||||
|
<button class="grassland-ndvi-timeline-nav" type="button" title="上一期" @click="step(-1)">‹</button> |
||||||
|
<div |
||||||
|
class="grassland-ndvi-timeline-track" |
||||||
|
:style="{ '--timeline-active-left': activePosition, '--timeline-fill-width': timelineFillWidth }" |
||||||
|
role="radiogroup" |
||||||
|
aria-label="草地健康退化月份" |
||||||
|
> |
||||||
|
<div class="grassland-ndvi-timeline-line"></div> |
||||||
|
<div class="grassland-ndvi-timeline-fill"></div> |
||||||
|
<div class="grassland-ndvi-timeline-thumb"> |
||||||
|
<i></i> |
||||||
|
</div> |
||||||
|
<button |
||||||
|
v-for="(item, index) in items" |
||||||
|
:key="item.key" |
||||||
|
class="grassland-ndvi-timeline-mark" |
||||||
|
:class="{ 'is-active': item.key === activeItem?.key, 'is-past': index <= currentIndex }" |
||||||
|
type="button" |
||||||
|
role="radio" |
||||||
|
:aria-checked="item.key === activeItem?.key" |
||||||
|
:style="{ left: getItemPosition(index) }" |
||||||
|
:title="item.date" |
||||||
|
@click="selectItem(item)" |
||||||
|
> |
||||||
|
<span class="grassland-ndvi-timeline-tick"></span> |
||||||
|
<span class="grassland-ndvi-timeline-label">{{ item.label }}</span> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<button class="grassland-ndvi-timeline-nav" type="button" title="下一期" @click="step(1)">›</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<script setup> |
||||||
|
import { computed } from "vue" |
||||||
|
|
||||||
|
const props = defineProps({ |
||||||
|
items: { |
||||||
|
type: Array, |
||||||
|
default: () => [], |
||||||
|
}, |
||||||
|
modelValue: { |
||||||
|
type: String, |
||||||
|
default: "", |
||||||
|
}, |
||||||
|
loading: { |
||||||
|
type: Boolean, |
||||||
|
default: false, |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]) |
||||||
|
const TIMELINE_EDGE_OFFSET = 42 |
||||||
|
|
||||||
|
const activeIndex = computed(() => props.items.findIndex((item) => item.key === props.modelValue)) |
||||||
|
const currentIndex = computed(() => activeIndex.value === -1 ? Math.max(0, props.items.length - 1) : activeIndex.value) |
||||||
|
const activeItem = computed(() => props.items[currentIndex.value] || null) |
||||||
|
const statusText = computed(() => (props.loading ? "加载中" : "未选择")) |
||||||
|
const activePosition = computed(() => getItemPosition(currentIndex.value)) |
||||||
|
const timelineFillWidth = computed(() => getTimelineFillWidth(currentIndex.value)) |
||||||
|
|
||||||
|
function step(direction) { |
||||||
|
if (!props.items.length) return |
||||||
|
const nextIndex = Math.min(props.items.length - 1, Math.max(0, currentIndex.value + direction)) |
||||||
|
emit("update:modelValue", props.items[nextIndex].key) |
||||||
|
} |
||||||
|
|
||||||
|
function selectItem(item) { |
||||||
|
if (!item || item.key === props.modelValue) return |
||||||
|
emit("update:modelValue", item.key) |
||||||
|
} |
||||||
|
|
||||||
|
function getItemRatio(index) { |
||||||
|
if (!props.items.length || index < 0) return 0 |
||||||
|
if (props.items.length === 1) return 0.5 |
||||||
|
return index / (props.items.length - 1) |
||||||
|
} |
||||||
|
|
||||||
|
function getItemPosition(index) { |
||||||
|
const ratio = getItemRatio(index) |
||||||
|
const percent = formatCssNumber(ratio * 100) |
||||||
|
const offset = TIMELINE_EDGE_OFFSET * (1 - ratio * 2) |
||||||
|
return `calc(${percent}% ${offset < 0 ? "-" : "+"} ${formatCssNumber(Math.abs(offset))}px)` |
||||||
|
} |
||||||
|
|
||||||
|
function getTimelineFillWidth(index) { |
||||||
|
const ratio = getItemRatio(index) |
||||||
|
if (ratio <= 0) return "0px" |
||||||
|
return `calc(${formatCssNumber(ratio * 100)}% - ${formatCssNumber(TIMELINE_EDGE_OFFSET * 2 * ratio)}px)` |
||||||
|
} |
||||||
|
|
||||||
|
function formatCssNumber(value) { |
||||||
|
return Number(value.toFixed(4)).toString() |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<style lang="scss" scoped> |
||||||
|
.grassland-ndvi-timeline { |
||||||
|
width: 680px; |
||||||
|
height: 68px; |
||||||
|
box-sizing: border-box; |
||||||
|
padding: 8px 12px 9px; |
||||||
|
border: 1px solid rgba(72, 226, 255, 0.28); |
||||||
|
background: |
||||||
|
linear-gradient(180deg, rgba(6, 36, 50, 0.76), rgba(3, 18, 30, 0.66)), |
||||||
|
linear-gradient(90deg, rgba(28, 191, 255, 0.08), rgba(38, 246, 198, 0.12), rgba(28, 191, 255, 0.08)); |
||||||
|
backdrop-filter: blur(6px); |
||||||
|
box-shadow: |
||||||
|
inset 0 1px 0 rgba(187, 250, 255, 0.12), |
||||||
|
inset 0 0 18px rgba(28, 191, 255, 0.08), |
||||||
|
0 10px 28px rgba(0, 12, 26, 0.34); |
||||||
|
color: #efffff; |
||||||
|
pointer-events: all; |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-head { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
height: 18px; |
||||||
|
margin-bottom: 8px; |
||||||
|
|
||||||
|
span { |
||||||
|
position: relative; |
||||||
|
padding-left: 10px; |
||||||
|
color: rgba(232, 252, 255, 0.86); |
||||||
|
font-size: 13px; |
||||||
|
font-weight: 700; |
||||||
|
|
||||||
|
&::before { |
||||||
|
content: ""; |
||||||
|
position: absolute; |
||||||
|
left: 0; |
||||||
|
top: 4px; |
||||||
|
width: 4px; |
||||||
|
height: 10px; |
||||||
|
background: #26f6c6; |
||||||
|
box-shadow: 0 0 10px rgba(38, 246, 198, 0.72); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
strong { |
||||||
|
color: #29ffcf; |
||||||
|
font-family: D-DIN, Arial, sans-serif; |
||||||
|
font-size: 20px; |
||||||
|
line-height: 18px; |
||||||
|
letter-spacing: 0; |
||||||
|
text-shadow: 0 0 12px rgba(41, 255, 207, 0.42); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-state { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
height: 30px; |
||||||
|
color: rgba(228, 251, 255, 0.68); |
||||||
|
font-size: 13px; |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-body { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: 28px minmax(0, 1fr) 28px; |
||||||
|
align-items: center; |
||||||
|
gap: 12px; |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-nav { |
||||||
|
width: 28px; |
||||||
|
height: 28px; |
||||||
|
padding: 0; |
||||||
|
border: 1px solid rgba(72, 226, 255, 0.34); |
||||||
|
background: rgba(7, 35, 48, 0.68); |
||||||
|
color: rgba(230, 253, 255, 0.88); |
||||||
|
font-size: 22px; |
||||||
|
line-height: 20px; |
||||||
|
cursor: pointer; |
||||||
|
outline: none; |
||||||
|
|
||||||
|
&:hover, |
||||||
|
&:focus-visible { |
||||||
|
color: #ffffff; |
||||||
|
border-color: rgba(48, 220, 255, 0.78); |
||||||
|
box-shadow: 0 0 12px rgba(48, 220, 255, 0.26); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-track { |
||||||
|
position: relative; |
||||||
|
height: 34px; |
||||||
|
margin: 0 8px; |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-line, |
||||||
|
.grassland-ndvi-timeline-fill { |
||||||
|
position: absolute; |
||||||
|
left: 42px; |
||||||
|
top: 8px; |
||||||
|
height: 3px; |
||||||
|
border-radius: 999px; |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-line { |
||||||
|
right: 42px; |
||||||
|
background: rgba(90, 199, 228, 0.28); |
||||||
|
box-shadow: inset 0 0 8px rgba(0, 7, 18, 0.28); |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-fill { |
||||||
|
right: auto; |
||||||
|
width: var(--timeline-fill-width); |
||||||
|
background: linear-gradient(90deg, #35bff8, #29ffcf); |
||||||
|
box-shadow: 0 0 12px rgba(41, 255, 207, 0.36); |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-thumb { |
||||||
|
position: absolute; |
||||||
|
z-index: 3; |
||||||
|
left: var(--timeline-active-left); |
||||||
|
top: 8px; |
||||||
|
width: 0; |
||||||
|
height: 0; |
||||||
|
pointer-events: none; |
||||||
|
transform: translateX(-50%); |
||||||
|
|
||||||
|
i { |
||||||
|
content: ""; |
||||||
|
position: absolute; |
||||||
|
left: -9px; |
||||||
|
top: -9px; |
||||||
|
width: 18px; |
||||||
|
height: 18px; |
||||||
|
box-sizing: border-box; |
||||||
|
border: 2px solid rgba(240, 255, 255, 0.92); |
||||||
|
border-radius: 50%; |
||||||
|
background: #29ffcf; |
||||||
|
box-shadow: |
||||||
|
0 0 0 5px rgba(41, 255, 207, 0.12), |
||||||
|
0 0 18px rgba(41, 255, 207, 0.66); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-mark { |
||||||
|
position: absolute; |
||||||
|
z-index: 2; |
||||||
|
top: 0; |
||||||
|
display: block; |
||||||
|
width: 84px; |
||||||
|
height: 34px; |
||||||
|
padding: 0; |
||||||
|
border: 0; |
||||||
|
background: transparent; |
||||||
|
color: rgba(232, 250, 255, 0.76); |
||||||
|
cursor: pointer; |
||||||
|
outline: none; |
||||||
|
transform: translateX(-50%); |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-tick { |
||||||
|
position: absolute; |
||||||
|
left: 50%; |
||||||
|
top: 4px; |
||||||
|
width: 2px; |
||||||
|
height: 11px; |
||||||
|
border-radius: 999px; |
||||||
|
background: rgba(148, 234, 255, 0.74); |
||||||
|
box-shadow: 0 0 8px rgba(48, 220, 255, 0.24); |
||||||
|
transform: translateX(-50%); |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-label { |
||||||
|
position: absolute; |
||||||
|
left: 50%; |
||||||
|
top: 18px; |
||||||
|
width: 78px; |
||||||
|
box-sizing: border-box; |
||||||
|
overflow: visible; |
||||||
|
padding: 0 3px; |
||||||
|
color: rgba(232, 250, 255, 0.62); |
||||||
|
font-size: 12px; |
||||||
|
font-weight: 700; |
||||||
|
line-height: 16px; |
||||||
|
text-align: center; |
||||||
|
text-overflow: clip; |
||||||
|
white-space: nowrap; |
||||||
|
transform: translateX(-50%); |
||||||
|
} |
||||||
|
|
||||||
|
&:hover, |
||||||
|
&:focus-visible, |
||||||
|
&.is-active { |
||||||
|
color: #ffffff; |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-tick { |
||||||
|
background: #29ffcf; |
||||||
|
box-shadow: 0 0 12px rgba(41, 255, 207, 0.58); |
||||||
|
} |
||||||
|
|
||||||
|
.grassland-ndvi-timeline-label { |
||||||
|
color: #ffffff; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
&.is-active { |
||||||
|
.grassland-ndvi-timeline-label { |
||||||
|
width: 78px; |
||||||
|
padding: 0 8px; |
||||||
|
border: 1px solid rgba(41, 255, 207, 0.34); |
||||||
|
background: rgba(4, 39, 48, 0.78); |
||||||
|
box-shadow: 0 0 14px rgba(41, 255, 207, 0.16); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,720 @@ |
|||||||
|
<template> |
||||||
|
<div class="yak-ol-map"> |
||||||
|
<div ref="mapEl" class="yak-ol-map__canvas"></div> |
||||||
|
<div |
||||||
|
v-if="coordinateCopyTip.visible" |
||||||
|
class="yak-ol-map__copy-tip" |
||||||
|
:class="{ 'is-error': coordinateCopyTip.error }" |
||||||
|
:style="{ left: `${coordinateCopyTip.x}px`, top: `${coordinateCopyTip.y}px` }" |
||||||
|
> |
||||||
|
{{ coordinateCopyTip.text }} |
||||||
|
</div> |
||||||
|
<div |
||||||
|
v-if="coordinateCopyMenu.visible" |
||||||
|
class="yak-ol-map__copy-menu" |
||||||
|
:class="{ 'is-error': coordinateCopyMenu.error }" |
||||||
|
:style="{ left: `${coordinateCopyMenu.x}px`, top: `${coordinateCopyMenu.y}px` }" |
||||||
|
@click.stop |
||||||
|
> |
||||||
|
<span>{{ coordinateCopyMenu.text }}</span> |
||||||
|
<button type="button" @click="handleCoordinateMenuCopy">复制</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<script setup> |
||||||
|
import "ol/ol.css" |
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue" |
||||||
|
import Map from "ol/Map.js" |
||||||
|
import View from "ol/View.js" |
||||||
|
import Feature from "ol/Feature.js" |
||||||
|
import GeoJSON from "ol/format/GeoJSON.js" |
||||||
|
import TileLayer from "ol/layer/Tile.js" |
||||||
|
import VectorLayer from "ol/layer/Vector.js" |
||||||
|
import Point from "ol/geom/Point.js" |
||||||
|
import TileWMS from "ol/source/TileWMS.js" |
||||||
|
import XYZ from "ol/source/XYZ.js" |
||||||
|
import VectorSource from "ol/source/Vector.js" |
||||||
|
import { defaults as defaultControls } from "ol/control/defaults.js" |
||||||
|
import { unByKey } from "ol/Observable.js" |
||||||
|
import { fromLonLat, toLonLat } from "ol/proj.js" |
||||||
|
import { Circle as CircleStyle, Fill, RegularShape, Stroke, Style, Text } from "ol/style.js" |
||||||
|
import { hongyuanTownshipLabelPoints, hongyuanTownshipsGeoJson } from "@/config/townshipBoundaries" |
||||||
|
|
||||||
|
const props = defineProps({ |
||||||
|
active: { |
||||||
|
type: Boolean, |
||||||
|
default: true, |
||||||
|
}, |
||||||
|
tileUrlTemplate: { |
||||||
|
type: String, |
||||||
|
default: "/hy-result/{z}/{x}/{y}.png", |
||||||
|
}, |
||||||
|
center: { |
||||||
|
type: Array, |
||||||
|
default: () => [103.07984, 33.029058], |
||||||
|
}, |
||||||
|
minZoom: { |
||||||
|
type: Number, |
||||||
|
default: 3, |
||||||
|
}, |
||||||
|
maxZoom: { |
||||||
|
type: Number, |
||||||
|
default: 21, |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const emit = defineEmits(["ready", "tile-status", "layer-status"]) |
||||||
|
|
||||||
|
const HONGYUAN_CENTER = [103.07984, 33.029058] |
||||||
|
const INITIAL_ZOOM = 21 |
||||||
|
const boundaryFormat = new GeoJSON() |
||||||
|
const boundaryGlowStyle = new Style({ |
||||||
|
stroke: new Stroke({ |
||||||
|
color: "rgba(48, 220, 255, 0.32)", |
||||||
|
width: 5, |
||||||
|
}), |
||||||
|
}) |
||||||
|
const boundaryLineStyle = new Style({ |
||||||
|
fill: new Fill({ |
||||||
|
color: "rgba(3, 18, 31, 0.02)", |
||||||
|
}), |
||||||
|
stroke: new Stroke({ |
||||||
|
color: "rgba(126, 251, 246, 0.82)", |
||||||
|
width: 1.4, |
||||||
|
}), |
||||||
|
}) |
||||||
|
const labelPointStyle = new Style({ |
||||||
|
image: new CircleStyle({ |
||||||
|
radius: 2.4, |
||||||
|
fill: new Fill({ color: "rgba(234, 255, 255, 0.92)" }), |
||||||
|
stroke: new Stroke({ color: "rgba(48, 220, 255, 0.9)", width: 1 }), |
||||||
|
}), |
||||||
|
}) |
||||||
|
|
||||||
|
const mapEl = ref(null) |
||||||
|
let olMap = null |
||||||
|
let baseSource = null |
||||||
|
let yakWmsSource = null |
||||||
|
let contextMenuHandler = null |
||||||
|
let rightPointerDownHandler = null |
||||||
|
let coordinateTipTimer = null |
||||||
|
let lastRightClickCopyTime = 0 |
||||||
|
let eventKeys = [] |
||||||
|
let wmsEventKeys = [] |
||||||
|
const coordinateCopyTip = ref({ |
||||||
|
visible: false, |
||||||
|
text: "", |
||||||
|
x: 0, |
||||||
|
y: 0, |
||||||
|
error: false, |
||||||
|
}) |
||||||
|
const coordinateCopyMenu = ref({ |
||||||
|
visible: false, |
||||||
|
text: "", |
||||||
|
x: 0, |
||||||
|
y: 0, |
||||||
|
error: false, |
||||||
|
}) |
||||||
|
const pendingTiles = new Set() |
||||||
|
const tileState = { |
||||||
|
requested: 0, |
||||||
|
loaded: 0, |
||||||
|
failed: 0, |
||||||
|
zoom: INITIAL_ZOOM, |
||||||
|
} |
||||||
|
|
||||||
|
function tileCoordKey(event) { |
||||||
|
const coord = event?.tile?.getTileCoord?.() |
||||||
|
return Array.isArray(coord) ? coord.join("/") : "" |
||||||
|
} |
||||||
|
|
||||||
|
function currentZoom() { |
||||||
|
const zoom = Number(olMap?.getView?.()?.getZoom?.()) |
||||||
|
return Number.isFinite(zoom) ? Math.round(zoom) : INITIAL_ZOOM |
||||||
|
} |
||||||
|
|
||||||
|
function normalizeCenter(center = props.center) { |
||||||
|
const [lng, lat] = Array.isArray(center) ? center : [] |
||||||
|
const nextLng = Number(lng) |
||||||
|
const nextLat = Number(lat) |
||||||
|
return Number.isFinite(nextLng) && Number.isFinite(nextLat) ? [nextLng, nextLat] : HONGYUAN_CENTER |
||||||
|
} |
||||||
|
|
||||||
|
function locateMap(center = props.center) { |
||||||
|
const view = olMap?.getView?.() |
||||||
|
if (!view) return |
||||||
|
resetTileState(INITIAL_ZOOM) |
||||||
|
view.setCenter(fromLonLat(normalizeCenter(center))) |
||||||
|
view.setZoom(INITIAL_ZOOM) |
||||||
|
window.setTimeout(() => olMap?.updateSize?.(), 0) |
||||||
|
} |
||||||
|
|
||||||
|
function resetTileState(zoom = currentZoom()) { |
||||||
|
pendingTiles.clear() |
||||||
|
tileState.requested = 0 |
||||||
|
tileState.loaded = 0 |
||||||
|
tileState.failed = 0 |
||||||
|
tileState.zoom = zoom |
||||||
|
} |
||||||
|
|
||||||
|
function emitTileStatus(type, text) { |
||||||
|
emit("tile-status", { |
||||||
|
type, |
||||||
|
text, |
||||||
|
loaded: tileState.loaded, |
||||||
|
failed: tileState.failed, |
||||||
|
total: tileState.requested, |
||||||
|
zoom: tileState.zoom, |
||||||
|
tileUrlTemplate: props.tileUrlTemplate, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function emitLayerStatus(type, text) { |
||||||
|
emit("layer-status", { |
||||||
|
type, |
||||||
|
text, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function buildBaseSource() { |
||||||
|
baseSource = new XYZ({ |
||||||
|
url: props.tileUrlTemplate, |
||||||
|
minZoom: props.minZoom, |
||||||
|
maxZoom: props.maxZoom, |
||||||
|
crossOrigin: "anonymous", |
||||||
|
interpolate: true, |
||||||
|
transition: 0, |
||||||
|
wrapX: false, |
||||||
|
}) |
||||||
|
|
||||||
|
eventKeys.push( |
||||||
|
baseSource.on("tileloadstart", (event) => { |
||||||
|
const key = tileCoordKey(event) |
||||||
|
if (key && pendingTiles.has(key)) return |
||||||
|
if (key) pendingTiles.add(key) |
||||||
|
tileState.requested += 1 |
||||||
|
tileState.zoom = currentZoom() |
||||||
|
emitTileStatus("loading", `正在加载 hy-result ${tileState.zoom}级影像底图`) |
||||||
|
}), |
||||||
|
) |
||||||
|
eventKeys.push( |
||||||
|
baseSource.on("tileloadend", (event) => { |
||||||
|
const key = tileCoordKey(event) |
||||||
|
if (key && !pendingTiles.has(key)) return |
||||||
|
if (key) pendingTiles.delete(key) |
||||||
|
tileState.loaded += 1 |
||||||
|
tileState.zoom = currentZoom() |
||||||
|
const done = pendingTiles.size === 0 |
||||||
|
emitTileStatus(done ? "success" : "loading", done ? "hy-result 影像底图已加载" : `正在加载 hy-result ${tileState.zoom}级影像底图`) |
||||||
|
}), |
||||||
|
) |
||||||
|
eventKeys.push( |
||||||
|
baseSource.on("tileloaderror", (event) => { |
||||||
|
const key = tileCoordKey(event) |
||||||
|
if (key && !pendingTiles.has(key)) return |
||||||
|
if (key) pendingTiles.delete(key) |
||||||
|
tileState.failed += 1 |
||||||
|
tileState.zoom = currentZoom() |
||||||
|
const type = tileState.loaded > 0 ? "warning" : "error" |
||||||
|
emitTileStatus(type, type === "warning" ? "hy-result 影像底图覆盖不足" : "hy-result 影像底图加载失败") |
||||||
|
}), |
||||||
|
) |
||||||
|
|
||||||
|
return baseSource |
||||||
|
} |
||||||
|
|
||||||
|
function buildYakWmsSource() { |
||||||
|
yakWmsSource = new TileWMS({ |
||||||
|
url: "/geoserver/ne/wms", |
||||||
|
params: { |
||||||
|
SERVICE: "WMS", |
||||||
|
VERSION: "1.1.1", |
||||||
|
LAYERS: "ne:daping_yak_marks", |
||||||
|
STYLES: "yak_marks", |
||||||
|
CQL_FILTER: "gov_show = true", |
||||||
|
FORMAT: "image/png", |
||||||
|
TRANSPARENT: true, |
||||||
|
TILED: true, |
||||||
|
}, |
||||||
|
serverType: "geoserver", |
||||||
|
crossOrigin: "anonymous", |
||||||
|
transition: 0, |
||||||
|
}) |
||||||
|
|
||||||
|
wmsEventKeys.push(yakWmsSource.on("tileloadstart", () => emitLayerStatus("loading", "正在加载牦牛识别标记"))) |
||||||
|
wmsEventKeys.push(yakWmsSource.on("tileloadend", () => emitLayerStatus("success", "牦牛识别标记已加载"))) |
||||||
|
wmsEventKeys.push(yakWmsSource.on("tileloaderror", () => emitLayerStatus("error", "牦牛识别标记加载失败"))) |
||||||
|
|
||||||
|
return yakWmsSource |
||||||
|
} |
||||||
|
|
||||||
|
function createBoundaryLayers() { |
||||||
|
const boundaryFeatures = boundaryFormat.readFeatures(hongyuanTownshipsGeoJson, { |
||||||
|
dataProjection: "EPSG:4326", |
||||||
|
featureProjection: "EPSG:3857", |
||||||
|
}) |
||||||
|
const boundarySource = new VectorSource({ |
||||||
|
features: boundaryFeatures, |
||||||
|
}) |
||||||
|
|
||||||
|
const labelFeatures = hongyuanTownshipLabelPoints |
||||||
|
.filter((item) => item.name && Array.isArray(item.center)) |
||||||
|
.map((item) => { |
||||||
|
const feature = new Feature({ |
||||||
|
geometry: new Point(fromLonLat(item.center)), |
||||||
|
name: item.name, |
||||||
|
}) |
||||||
|
feature.setId(`yak-town-label-${item.name}`) |
||||||
|
return feature |
||||||
|
}) |
||||||
|
const labelSource = new VectorSource({ |
||||||
|
features: labelFeatures, |
||||||
|
}) |
||||||
|
|
||||||
|
return [ |
||||||
|
new VectorLayer({ |
||||||
|
source: boundarySource, |
||||||
|
style: boundaryGlowStyle, |
||||||
|
zIndex: 28, |
||||||
|
renderBuffer: 80, |
||||||
|
updateWhileAnimating: true, |
||||||
|
updateWhileInteracting: true, |
||||||
|
}), |
||||||
|
new VectorLayer({ |
||||||
|
source: boundarySource, |
||||||
|
style: boundaryLineStyle, |
||||||
|
zIndex: 29, |
||||||
|
renderBuffer: 80, |
||||||
|
updateWhileAnimating: true, |
||||||
|
updateWhileInteracting: true, |
||||||
|
}), |
||||||
|
new VectorLayer({ |
||||||
|
source: labelSource, |
||||||
|
declutter: true, |
||||||
|
zIndex: 40, |
||||||
|
renderBuffer: 120, |
||||||
|
updateWhileAnimating: true, |
||||||
|
updateWhileInteracting: true, |
||||||
|
style: createTownLabelStyle, |
||||||
|
}), |
||||||
|
] |
||||||
|
} |
||||||
|
|
||||||
|
function createTownLabelStyle(feature, resolution) { |
||||||
|
const name = feature.get("name") || "" |
||||||
|
const compact = resolution > 150 |
||||||
|
const hidden = resolution > 420 |
||||||
|
if (hidden) return labelPointStyle |
||||||
|
const fontSize = compact ? 12 : 13 |
||||||
|
const minWidthPadding = compact ? [4, 8, 4, 8] : [5, 10, 5, 10] |
||||||
|
return [ |
||||||
|
new Style({ |
||||||
|
image: new RegularShape({ |
||||||
|
points: 3, |
||||||
|
radius: compact ? 4 : 5, |
||||||
|
rotation: Math.PI, |
||||||
|
displacement: [0, 14], |
||||||
|
fill: new Fill({ color: "rgba(126, 251, 246, 0.92)" }), |
||||||
|
stroke: new Stroke({ color: "rgba(4, 28, 40, 0.95)", width: 1.2 }), |
||||||
|
}), |
||||||
|
}), |
||||||
|
new Style({ |
||||||
|
text: new Text({ |
||||||
|
text: name, |
||||||
|
font: `800 ${fontSize}px "Microsoft YaHei", "PingFang SC", sans-serif`, |
||||||
|
fill: new Fill({ color: "#ffffff" }), |
||||||
|
stroke: new Stroke({ color: "rgba(2, 18, 28, 0.96)", width: 4 }), |
||||||
|
backgroundFill: new Fill({ color: "rgba(3, 21, 33, 0.78)" }), |
||||||
|
backgroundStroke: new Stroke({ color: "rgba(126, 251, 246, 0.36)", width: 1 }), |
||||||
|
padding: minWidthPadding, |
||||||
|
offsetY: -17, |
||||||
|
}), |
||||||
|
}), |
||||||
|
] |
||||||
|
} |
||||||
|
|
||||||
|
function formatCoordinate(value) { |
||||||
|
const number = Number(value) |
||||||
|
if (!Number.isFinite(number)) return "0.000000" |
||||||
|
return number.toFixed(6) |
||||||
|
} |
||||||
|
|
||||||
|
async function copyTextToClipboard(text) { |
||||||
|
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { |
||||||
|
try { |
||||||
|
await navigator.clipboard.writeText(text) |
||||||
|
return |
||||||
|
} catch (error) { |
||||||
|
// Fall back to textarea copy below. Some browsers do not allow clipboard writes from contextmenu. |
||||||
|
} |
||||||
|
} |
||||||
|
const textarea = document.createElement("textarea") |
||||||
|
textarea.value = text |
||||||
|
textarea.setAttribute("readonly", "readonly") |
||||||
|
textarea.style.position = "fixed" |
||||||
|
textarea.style.left = "-9999px" |
||||||
|
textarea.style.top = "0" |
||||||
|
textarea.style.opacity = "0" |
||||||
|
try { |
||||||
|
document.body.appendChild(textarea) |
||||||
|
textarea.focus({ preventScroll: true }) |
||||||
|
textarea.select() |
||||||
|
textarea.setSelectionRange(0, text.length) |
||||||
|
const success = document.execCommand("copy") |
||||||
|
if (!success) throw new Error("copy failed") |
||||||
|
} finally { |
||||||
|
document.body.removeChild(textarea) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function showCoordinateCopyTip(event, text, error = false) { |
||||||
|
if (coordinateTipTimer) window.clearTimeout(coordinateTipTimer) |
||||||
|
coordinateCopyMenu.value = { |
||||||
|
...coordinateCopyMenu.value, |
||||||
|
visible: false, |
||||||
|
} |
||||||
|
const tipWidth = 270 |
||||||
|
const tipHeight = 32 |
||||||
|
const viewportWidth = window.innerWidth || 1920 |
||||||
|
const viewportHeight = window.innerHeight || 1080 |
||||||
|
coordinateCopyTip.value = { |
||||||
|
visible: true, |
||||||
|
text, |
||||||
|
x: Math.min(Math.max(12, event.clientX + 12), Math.max(12, viewportWidth - tipWidth)), |
||||||
|
y: Math.min(Math.max(12, event.clientY + 12), Math.max(12, viewportHeight - tipHeight)), |
||||||
|
error, |
||||||
|
} |
||||||
|
coordinateTipTimer = window.setTimeout(() => { |
||||||
|
coordinateCopyTip.value = { |
||||||
|
...coordinateCopyTip.value, |
||||||
|
visible: false, |
||||||
|
} |
||||||
|
coordinateTipTimer = null |
||||||
|
}, 1500) |
||||||
|
} |
||||||
|
|
||||||
|
function showCoordinateCopyMenu(event, text, error = false) { |
||||||
|
if (coordinateTipTimer) { |
||||||
|
window.clearTimeout(coordinateTipTimer) |
||||||
|
coordinateTipTimer = null |
||||||
|
} |
||||||
|
const menuWidth = 300 |
||||||
|
const menuHeight = 38 |
||||||
|
const viewportWidth = window.innerWidth || 1920 |
||||||
|
const viewportHeight = window.innerHeight || 1080 |
||||||
|
coordinateCopyTip.value = { |
||||||
|
...coordinateCopyTip.value, |
||||||
|
visible: false, |
||||||
|
} |
||||||
|
coordinateCopyMenu.value = { |
||||||
|
visible: true, |
||||||
|
text, |
||||||
|
x: Math.min(Math.max(12, event.clientX + 12), Math.max(12, viewportWidth - menuWidth)), |
||||||
|
y: Math.min(Math.max(12, event.clientY + 12), Math.max(12, viewportHeight - menuHeight)), |
||||||
|
error, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function hideCoordinateCopyMenu() { |
||||||
|
coordinateCopyMenu.value = { |
||||||
|
...coordinateCopyMenu.value, |
||||||
|
visible: false, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function copyCoordinateFromMapEvent(event) { |
||||||
|
if (!olMap) return |
||||||
|
const pixel = olMap.getEventPixel(event) |
||||||
|
const coordinate = olMap.getCoordinateFromPixel(pixel) |
||||||
|
if (!coordinate) return |
||||||
|
const [lng, lat] = toLonLat(coordinate) |
||||||
|
const text = `${formatCoordinate(lng)}, ${formatCoordinate(lat)}` |
||||||
|
try { |
||||||
|
await copyTextToClipboard(text) |
||||||
|
showCoordinateCopyTip(event, `已复制 ${text}`) |
||||||
|
} catch (error) { |
||||||
|
showCoordinateCopyMenu(event, text, true) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleRightPointerDown(event) { |
||||||
|
if (event.button !== 2) { |
||||||
|
hideCoordinateCopyMenu() |
||||||
|
return |
||||||
|
} |
||||||
|
event.preventDefault() |
||||||
|
event.stopPropagation() |
||||||
|
lastRightClickCopyTime = Date.now() |
||||||
|
copyCoordinateFromMapEvent(event) |
||||||
|
} |
||||||
|
|
||||||
|
async function handleCoordinateMenuCopy() { |
||||||
|
const text = coordinateCopyMenu.value.text |
||||||
|
if (!text) return |
||||||
|
try { |
||||||
|
await copyTextToClipboard(text) |
||||||
|
coordinateCopyMenu.value = { |
||||||
|
...coordinateCopyMenu.value, |
||||||
|
visible: false, |
||||||
|
error: false, |
||||||
|
} |
||||||
|
coordinateCopyTip.value = { |
||||||
|
visible: true, |
||||||
|
text: `已复制 ${text}`, |
||||||
|
x: coordinateCopyMenu.value.x, |
||||||
|
y: coordinateCopyMenu.value.y, |
||||||
|
error: false, |
||||||
|
} |
||||||
|
if (coordinateTipTimer) window.clearTimeout(coordinateTipTimer) |
||||||
|
coordinateTipTimer = window.setTimeout(() => { |
||||||
|
coordinateCopyTip.value = { |
||||||
|
...coordinateCopyTip.value, |
||||||
|
visible: false, |
||||||
|
} |
||||||
|
coordinateTipTimer = null |
||||||
|
}, 1500) |
||||||
|
} catch (error) { |
||||||
|
coordinateCopyMenu.value = { |
||||||
|
...coordinateCopyMenu.value, |
||||||
|
error: true, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleContextMenu(event) { |
||||||
|
event.preventDefault() |
||||||
|
event.stopPropagation() |
||||||
|
if (Date.now() - lastRightClickCopyTime < 800) return |
||||||
|
lastRightClickCopyTime = Date.now() |
||||||
|
copyCoordinateFromMapEvent(event) |
||||||
|
} |
||||||
|
|
||||||
|
function bindContextMenuCopy() { |
||||||
|
const viewport = olMap?.getViewport?.() |
||||||
|
if (!viewport || contextMenuHandler) return |
||||||
|
rightPointerDownHandler = (event) => { |
||||||
|
handleRightPointerDown(event) |
||||||
|
} |
||||||
|
contextMenuHandler = (event) => { |
||||||
|
handleContextMenu(event) |
||||||
|
} |
||||||
|
viewport.addEventListener("pointerdown", rightPointerDownHandler) |
||||||
|
viewport.addEventListener("contextmenu", contextMenuHandler) |
||||||
|
} |
||||||
|
|
||||||
|
function unbindContextMenuCopy() { |
||||||
|
const viewport = olMap?.getViewport?.() |
||||||
|
if (viewport && rightPointerDownHandler) { |
||||||
|
viewport.removeEventListener("pointerdown", rightPointerDownHandler) |
||||||
|
} |
||||||
|
if (viewport && contextMenuHandler) { |
||||||
|
viewport.removeEventListener("contextmenu", contextMenuHandler) |
||||||
|
} |
||||||
|
rightPointerDownHandler = null |
||||||
|
contextMenuHandler = null |
||||||
|
if (coordinateTipTimer) { |
||||||
|
window.clearTimeout(coordinateTipTimer) |
||||||
|
coordinateTipTimer = null |
||||||
|
} |
||||||
|
hideCoordinateCopyMenu() |
||||||
|
} |
||||||
|
|
||||||
|
async function initMap() { |
||||||
|
if (olMap || !props.active) return |
||||||
|
await nextTick() |
||||||
|
if (!mapEl.value || olMap || !props.active) return |
||||||
|
|
||||||
|
resetTileState(INITIAL_ZOOM) |
||||||
|
const view = new View({ |
||||||
|
center: fromLonLat(normalizeCenter()), |
||||||
|
zoom: INITIAL_ZOOM, |
||||||
|
minZoom: props.minZoom, |
||||||
|
maxZoom: props.maxZoom, |
||||||
|
enableRotation: false, |
||||||
|
constrainResolution: false, |
||||||
|
smoothResolutionConstraint: true, |
||||||
|
}) |
||||||
|
|
||||||
|
olMap = new Map({ |
||||||
|
target: mapEl.value, |
||||||
|
controls: defaultControls({ |
||||||
|
attribution: false, |
||||||
|
rotate: false, |
||||||
|
zoom: false, |
||||||
|
}), |
||||||
|
layers: [ |
||||||
|
new TileLayer({ |
||||||
|
source: buildBaseSource(), |
||||||
|
preload: 1, |
||||||
|
zIndex: 1, |
||||||
|
}), |
||||||
|
new TileLayer({ |
||||||
|
source: buildYakWmsSource(), |
||||||
|
opacity: 0.96, |
||||||
|
zIndex: 20, |
||||||
|
}), |
||||||
|
...createBoundaryLayers(), |
||||||
|
], |
||||||
|
view, |
||||||
|
}) |
||||||
|
bindContextMenuCopy() |
||||||
|
|
||||||
|
eventKeys.push( |
||||||
|
view.on("change:resolution", () => { |
||||||
|
const zoom = currentZoom() |
||||||
|
if (zoom !== tileState.zoom) { |
||||||
|
resetTileState(zoom) |
||||||
|
emitTileStatus("loading", `正在加载 hy-result ${zoom}级影像底图`) |
||||||
|
} |
||||||
|
}), |
||||||
|
) |
||||||
|
|
||||||
|
window.setTimeout(() => { |
||||||
|
olMap?.updateSize?.() |
||||||
|
emit("ready") |
||||||
|
}, 0) |
||||||
|
} |
||||||
|
|
||||||
|
function disposeMap() { |
||||||
|
unbindContextMenuCopy() |
||||||
|
if (eventKeys.length) { |
||||||
|
unByKey(eventKeys) |
||||||
|
eventKeys = [] |
||||||
|
} |
||||||
|
if (wmsEventKeys.length) { |
||||||
|
unByKey(wmsEventKeys) |
||||||
|
wmsEventKeys = [] |
||||||
|
} |
||||||
|
pendingTiles.clear() |
||||||
|
baseSource = null |
||||||
|
yakWmsSource = null |
||||||
|
if (olMap) { |
||||||
|
olMap.setTarget(null) |
||||||
|
olMap.dispose?.() |
||||||
|
olMap = null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
initMap() |
||||||
|
}) |
||||||
|
|
||||||
|
onBeforeUnmount(() => { |
||||||
|
disposeMap() |
||||||
|
}) |
||||||
|
|
||||||
|
watch( |
||||||
|
() => props.active, |
||||||
|
(active) => { |
||||||
|
if (active) { |
||||||
|
initMap() |
||||||
|
window.setTimeout(() => { |
||||||
|
locateMap() |
||||||
|
olMap?.updateSize?.() |
||||||
|
}, 0) |
||||||
|
} |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
watch( |
||||||
|
() => props.center, |
||||||
|
() => { |
||||||
|
locateMap() |
||||||
|
}, |
||||||
|
{ deep: true }, |
||||||
|
) |
||||||
|
|
||||||
|
watch( |
||||||
|
() => props.tileUrlTemplate, |
||||||
|
() => { |
||||||
|
if (!baseSource) return |
||||||
|
resetTileState(currentZoom()) |
||||||
|
baseSource.setUrl(props.tileUrlTemplate) |
||||||
|
baseSource.refresh() |
||||||
|
emitTileStatus("loading", `正在加载 hy-result ${tileState.zoom}级影像底图`) |
||||||
|
}, |
||||||
|
) |
||||||
|
</script> |
||||||
|
|
||||||
|
<style scoped lang="scss"> |
||||||
|
.yak-ol-map { |
||||||
|
position: absolute; |
||||||
|
inset: 0; |
||||||
|
z-index: 1; |
||||||
|
overflow: hidden; |
||||||
|
background: #03121c; |
||||||
|
} |
||||||
|
|
||||||
|
.yak-ol-map__canvas { |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
.yak-ol-map__copy-tip { |
||||||
|
position: fixed; |
||||||
|
z-index: 30; |
||||||
|
max-width: 260px; |
||||||
|
padding: 7px 10px; |
||||||
|
border: 1px solid rgba(126, 251, 246, 0.4); |
||||||
|
border-radius: 3px; |
||||||
|
color: rgba(235, 255, 255, 0.94); |
||||||
|
font-size: 12px; |
||||||
|
font-weight: 700; |
||||||
|
line-height: 1; |
||||||
|
background: rgba(3, 21, 33, 0.86); |
||||||
|
box-shadow: 0 0 14px rgba(48, 220, 255, 0.16); |
||||||
|
pointer-events: none; |
||||||
|
transform: translateY(-50%); |
||||||
|
white-space: nowrap; |
||||||
|
|
||||||
|
&.is-error { |
||||||
|
border-color: rgba(255, 143, 107, 0.55); |
||||||
|
color: #ffe0d6; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.yak-ol-map__copy-menu { |
||||||
|
position: fixed; |
||||||
|
z-index: 30; |
||||||
|
display: flex; |
||||||
|
height: 34px; |
||||||
|
align-items: center; |
||||||
|
gap: 8px; |
||||||
|
padding: 0 8px 0 10px; |
||||||
|
border: 1px solid rgba(126, 251, 246, 0.42); |
||||||
|
border-radius: 3px; |
||||||
|
color: rgba(235, 255, 255, 0.94); |
||||||
|
font-size: 12px; |
||||||
|
font-weight: 700; |
||||||
|
line-height: 1; |
||||||
|
background: rgba(3, 21, 33, 0.92); |
||||||
|
box-shadow: 0 0 14px rgba(48, 220, 255, 0.18); |
||||||
|
pointer-events: all; |
||||||
|
white-space: nowrap; |
||||||
|
|
||||||
|
span { |
||||||
|
font-variant-numeric: tabular-nums; |
||||||
|
} |
||||||
|
|
||||||
|
button { |
||||||
|
height: 22px; |
||||||
|
padding: 0 8px; |
||||||
|
border: 1px solid rgba(126, 251, 246, 0.38); |
||||||
|
border-radius: 2px; |
||||||
|
color: #031822; |
||||||
|
font-size: 12px; |
||||||
|
font-weight: 800; |
||||||
|
background: linear-gradient(180deg, #7efbf6, #30dcff); |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
&.is-error { |
||||||
|
border-color: rgba(255, 215, 108, 0.48); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
:deep(.ol-viewport) { |
||||||
|
background: #03121c; |
||||||
|
} |
||||||
|
</style> |
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue