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> |
||||
Loading…
Reference in new issue