many changes
This commit is contained in:
parent
5b800dcce8
commit
2598cdc3a9
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_AMAP_API_KEY=xxx
|
||||
VITE_BACKEND_URL=http://kuroneko.bison-banana.ts.net:35000
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
target
|
||||
dist
|
||||
.env
|
||||
.env
|
||||
.vite-ssg-temp
|
||||
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "msedge",
|
||||
"request": "launch",
|
||||
"name": "Launch Edge against localhost",
|
||||
"url": "http://localhost:3333",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -6,7 +6,7 @@
|
||||
"build": "vite-ssg build",
|
||||
"dev": "vite --port 3333 --open",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"preview": "vite preview --port 35122",
|
||||
"preview-https": "serve dist",
|
||||
"test": "vitest",
|
||||
"test:e2e": "cypress open",
|
||||
@ -85,7 +85,8 @@
|
||||
"vite-ssg": "^25.0.0",
|
||||
"vite-ssg-sitemap": "^0.8.1",
|
||||
"vitest": "^2.1.8",
|
||||
"vue-tsc": "^2.2.0"
|
||||
"vue-tsc": "^2.2.0",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"unplugin": "^2.1.2",
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@ -196,7 +196,7 @@ importers:
|
||||
version: 10.0.6(rollup@4.30.1)(vite@6.0.7(@types/node@20.2.3)(jiti@2.4.0)(stylus@0.57.0)(terser@5.17.6)(tsx@4.19.2)(yaml@2.6.1))
|
||||
vite-plugin-pwa:
|
||||
specifier: ^0.21.1
|
||||
version: 0.21.1(vite@6.0.7(@types/node@20.2.3)(jiti@2.4.0)(stylus@0.57.0)(terser@5.17.6)(tsx@4.19.2)(yaml@2.6.1))(workbox-build@7.1.1)(workbox-window@7.1.0)
|
||||
version: 0.21.1(vite@6.0.7(@types/node@20.2.3)(jiti@2.4.0)(stylus@0.57.0)(terser@5.17.6)(tsx@4.19.2)(yaml@2.6.1))(workbox-build@7.1.1)(workbox-window@7.3.0)
|
||||
vite-plugin-vue-devtools:
|
||||
specifier: ^7.7.0
|
||||
version: 7.7.0(rollup@4.30.1)(vite@6.0.7(@types/node@20.2.3)(jiti@2.4.0)(stylus@0.57.0)(terser@5.17.6)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.13(typescript@5.7.3))
|
||||
@ -215,6 +215,9 @@ importers:
|
||||
vue-tsc:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(typescript@5.7.3)
|
||||
workbox-window:
|
||||
specifier: ^7.3.0
|
||||
version: 7.3.0
|
||||
|
||||
packages:
|
||||
|
||||
@ -6179,6 +6182,9 @@ packages:
|
||||
workbox-core@7.1.0:
|
||||
resolution: {integrity: sha512-5KB4KOY8rtL31nEF7BfvU7FMzKT4B5TkbYa2tzkS+Peqj0gayMT9SytSFtNzlrvMaWgv6y/yvP9C0IbpFjV30Q==}
|
||||
|
||||
workbox-core@7.3.0:
|
||||
resolution: {integrity: sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==}
|
||||
|
||||
workbox-expiration@7.1.0:
|
||||
resolution: {integrity: sha512-m5DcMY+A63rJlPTbbBNtpJ20i3enkyOtSgYfv/l8h+D6YbbNiA0zKEkCUaMsdDlxggla1oOfRkyqTvl5Ni5KQQ==}
|
||||
|
||||
@ -6212,6 +6218,9 @@ packages:
|
||||
workbox-window@7.1.0:
|
||||
resolution: {integrity: sha512-ZHeROyqR+AS5UPzholQRDttLFqGMwP0Np8MKWAdyxsDETxq3qOAyXvqessc3GniohG6e0mAqSQyKOHmT8zPF7g==}
|
||||
|
||||
workbox-window@7.3.0:
|
||||
resolution: {integrity: sha512-qW8PDy16OV1UBaUNGlTVcepzrlzyzNW/ZJvFQQs2j2TzGsg6IKjcpZC1RSquqQnTOafl5pCj5bGfAHlCjOOjdA==}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
@ -13162,14 +13171,14 @@ snapshots:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
vite-plugin-pwa@0.21.1(vite@6.0.7(@types/node@20.2.3)(jiti@2.4.0)(stylus@0.57.0)(terser@5.17.6)(tsx@4.19.2)(yaml@2.6.1))(workbox-build@7.1.1)(workbox-window@7.1.0):
|
||||
vite-plugin-pwa@0.21.1(vite@6.0.7(@types/node@20.2.3)(jiti@2.4.0)(stylus@0.57.0)(terser@5.17.6)(tsx@4.19.2)(yaml@2.6.1))(workbox-build@7.1.1)(workbox-window@7.3.0):
|
||||
dependencies:
|
||||
debug: 4.4.0(supports-color@8.1.1)
|
||||
pretty-bytes: 6.1.1
|
||||
tinyglobby: 0.2.10
|
||||
vite: 6.0.7(@types/node@20.2.3)(jiti@2.4.0)(stylus@0.57.0)(terser@5.17.6)(tsx@4.19.2)(yaml@2.6.1)
|
||||
workbox-build: 7.1.1
|
||||
workbox-window: 7.1.0
|
||||
workbox-window: 7.3.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@ -13485,6 +13494,8 @@ snapshots:
|
||||
|
||||
workbox-core@7.1.0: {}
|
||||
|
||||
workbox-core@7.3.0: {}
|
||||
|
||||
workbox-expiration@7.1.0:
|
||||
dependencies:
|
||||
idb: 7.1.1
|
||||
@ -13540,6 +13551,11 @@ snapshots:
|
||||
'@types/trusted-types': 2.0.3
|
||||
workbox-core: 7.1.0
|
||||
|
||||
workbox-window@7.3.0:
|
||||
dependencies:
|
||||
'@types/trusted-types': 2.0.3
|
||||
workbox-core: 7.3.0
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
// you can use this to manipulate the document head in any components,
|
||||
// they will be rendered correctly in the html results with vite-ssg
|
||||
useHead({
|
||||
title: 'Project Vertex',
|
||||
title: '季节内台风预报系统',
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
|
||||
BIN
src/assets/colorbar.png
Normal file
BIN
src/assets/colorbar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
4
src/components.d.ts
vendored
4
src/components.d.ts
vendored
@ -7,6 +7,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AMapClient: typeof import('./components/AMapClient.vue')['default']
|
||||
Badge: typeof import('./components/ui/badge/Badge.vue')['default']
|
||||
Button: typeof import('./components/ui/button/Button.vue')['default']
|
||||
Calendar: typeof import('./components/ui/calendar/Calendar.vue')['default']
|
||||
@ -42,7 +43,6 @@ declare module 'vue' {
|
||||
PopoverContent: typeof import('./components/ui/popover/PopoverContent.vue')['default']
|
||||
PopoverTrigger: typeof import('./components/ui/popover/PopoverTrigger.vue')['default']
|
||||
PosForm: typeof import('./components/PosForm.vue')['default']
|
||||
README: typeof import('./components/README.md')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Select: typeof import('./components/ui/select/Select.vue')['default']
|
||||
@ -66,8 +66,6 @@ declare module 'vue' {
|
||||
TableHeader: typeof import('./components/ui/table/TableHeader.vue')['default']
|
||||
TableRow: typeof import('./components/ui/table/TableRow.vue')['default']
|
||||
Textarea: typeof import('./components/ui/textarea/Textarea.vue')['default']
|
||||
TheCounter: typeof import('./components/TheCounter.vue')['default']
|
||||
TheFooter: typeof import('./components/TheFooter.vue')['default']
|
||||
TheInput: typeof import('./components/TheInput.vue')['default']
|
||||
Tooltip: typeof import('./components/ui/tooltip/Tooltip.vue')['default']
|
||||
TooltipContent: typeof import('./components/ui/tooltip/TooltipContent.vue')['default']
|
||||
|
||||
162
src/components/AMapClient.vue
Normal file
162
src/components/AMapClient.vue
Normal file
@ -0,0 +1,162 @@
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: map
|
||||
</route>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useOptions } from '@/composables/dateOpt'
|
||||
import '@amap/amap-jsapi-types'
|
||||
|
||||
const amapInstance = ref<null | typeof AMap>(null)
|
||||
const map = ref<null | AMap.Map>(null)
|
||||
const previousLayer = ref<null | AMap.ImageLayer>(null)
|
||||
|
||||
const FORCE_MODE_OVERRIDE = 'image' as 'image' | 'heatmap' | null
|
||||
|
||||
function getPostionText(position: [number, number]) {
|
||||
const [x, y] = position
|
||||
function getX() {
|
||||
// add E or W
|
||||
const suffix = x < 180 ? 'E°' : 'W°'
|
||||
return `${x.toFixed(2)} ${suffix}`
|
||||
}
|
||||
function getY() {
|
||||
// add N or S
|
||||
const suffix = y < 0 ? 'S°' : 'N°'
|
||||
return `${y.toFixed(2)} ${suffix}`
|
||||
}
|
||||
return { getX, getY }
|
||||
}
|
||||
|
||||
const apiKey = import.meta.env.VITE_AMAP_API_KEY
|
||||
|
||||
const { options, currentFocus, getImageUrl, getHeatPoints } = useOptions()
|
||||
|
||||
watch(options, async () => {
|
||||
if (!map.value || !amapInstance.value) {
|
||||
return
|
||||
}
|
||||
if (options.castBeginDate === '' || options.castDayNo === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const mode = FORCE_MODE_OVERRIDE ?? options.renderMode
|
||||
|
||||
if (mode === 'heatmap') {
|
||||
await updateMapHeatpoints()
|
||||
}
|
||||
if (mode === 'image') {
|
||||
await updateMapImage()
|
||||
}
|
||||
})
|
||||
|
||||
watch(currentFocus, (newFocus) => {
|
||||
if (!newFocus) {
|
||||
return
|
||||
}
|
||||
const [lat, lon] = newFocus()
|
||||
// debugger
|
||||
map.value?.setCenter([lat, lon])
|
||||
map.value?.setZoom(4)
|
||||
})
|
||||
|
||||
const isDark = useDark()
|
||||
|
||||
watch(isDark, (newValue) => {
|
||||
map.value?.setMapStyle(newValue ? 'amap://styles/darkblue' : 'amap://styles/whitesmoke')
|
||||
})
|
||||
|
||||
async function updateMapImage() {
|
||||
if (!map.value || !amapInstance.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = await getImageUrl()
|
||||
|
||||
const image = new amapInstance.value.ImageLayer({
|
||||
url,
|
||||
// @ts-expect-error AMap messed up their definition
|
||||
bounds: new AMap.Bounds([-180, -35], [180, 35]),
|
||||
// bounds: [-150, -35, 180, 35],
|
||||
zIndex: 9,
|
||||
opacity: 1,
|
||||
// zooms: [15, 20],
|
||||
})
|
||||
map.value.add(image)
|
||||
image.on('complete', () => {
|
||||
previousLayer.value?.hide()
|
||||
previousLayer.value = image
|
||||
})
|
||||
}
|
||||
|
||||
async function updateMapHeatpoints() {
|
||||
if (!map.value || !amapInstance.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await getHeatPoints()
|
||||
|
||||
let heatMap: any
|
||||
const _map = map.value
|
||||
// @ts-expect-error Amap not providing heatmap types
|
||||
_map.plugin(['AMap.Heatmap'], () => {
|
||||
// @ts-expect-error Amap not providing heatmap types
|
||||
heatMap = new amapInstance.value.HeatMap(_map, {
|
||||
|
||||
radius: 100, // 给定半径
|
||||
opacity: [0, 0.8],
|
||||
|
||||
})
|
||||
heatMap.setDataSet({
|
||||
data,
|
||||
max: 1000,
|
||||
})
|
||||
heatMap.show()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!import.meta.env.SSR) {
|
||||
const AMapLoader = await import('@amap/amap-jsapi-loader')
|
||||
const _AMap: typeof AMap = await AMapLoader.load({
|
||||
key: apiKey,
|
||||
version: '2.0',
|
||||
plugins: [],
|
||||
})
|
||||
amapInstance.value = _AMap
|
||||
|
||||
map.value = new _AMap.Map('amap-container', {
|
||||
zoom: 5,
|
||||
center: [130, 17],
|
||||
zooms: [4, 20],
|
||||
mapStyle: isDark.value ? 'amap://styles/darkblue' : 'amap://styles/whitesmoke',
|
||||
// layers: [
|
||||
/* new AMap.TileLayer.Satellite() */
|
||||
// new AMap.TileLayer.RoadNet(),
|
||||
// ],
|
||||
})
|
||||
|
||||
map.value.setBounds(new AMap.Bounds([-180, -35], [180, 35]))
|
||||
map.value.setLimitBounds(new AMap.Bounds([-180, -35], [180, 35]))
|
||||
map.value.setZoom(5)
|
||||
|
||||
map.value.on('click', (e) => {
|
||||
const position = [e.lnglat.lng, e.lnglat.lat] as [number, number]
|
||||
const infoWindow = new amapInstance.value!.InfoWindow({
|
||||
content: `
|
||||
<div class="p-2 text-black">
|
||||
${getPostionText(position).getX()}
|
||||
,
|
||||
${getPostionText(position).getY()}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
infoWindow.open(map.value!, e.lnglat)
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="amap-container" class="m-0 h-full w-full p-0" />
|
||||
</template>
|
||||
@ -26,6 +26,15 @@ onMounted(async () => {
|
||||
const data = resp.data.value.data
|
||||
mapping.value = data
|
||||
options.mapping = data
|
||||
const date = Object.keys(data)[0]
|
||||
const newDateStr = `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`
|
||||
|
||||
if (castBeginDate.value === undefined) {
|
||||
castBeginDate.value = parseDate(newDateStr)
|
||||
}
|
||||
if (options.castDayNo === -1) {
|
||||
castTargetDate.value = castBeginDate.value.add({ days: 1 })
|
||||
}
|
||||
})
|
||||
|
||||
const isDateDisabled = computed(() => {
|
||||
@ -68,10 +77,10 @@ const isTargetDateDisabled = computed(() => {
|
||||
<form class="grid w-full items-start gap-6">
|
||||
<fieldset class="grid gap-6 border rounded-lg p-4">
|
||||
<legend class="px-1 text-sm font-medium -ml-1">
|
||||
日期设置
|
||||
<span class="i-material-symbols-settings size-5" />
|
||||
</legend>
|
||||
<div class="grid gap-3">
|
||||
<Label for="model">渲染模式</Label>
|
||||
<Label for="model">展示方式</Label>
|
||||
<Select v-model="options.renderMode">
|
||||
<SelectTrigger
|
||||
id="model"
|
||||
@ -131,8 +140,12 @@ const isTargetDateDisabled = computed(() => {
|
||||
{{ castBeginDate ? df.format(castBeginDate.toDate(getLocalTimeZone())) : "Pick a date" }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="z-300 w-auto p-0">
|
||||
<Calendar v-model="castBeginDate" class="z-300" initial-focus :is-date-disabled="isDateDisabled" />
|
||||
<PopoverContent class="w-aut1o z-300 p-0">
|
||||
<Calendar
|
||||
v-model="castBeginDate" class="z-300"
|
||||
initial-focus :is-date-disabled="isDateDisabled"
|
||||
prevent-deselect
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
@ -157,6 +170,7 @@ const isTargetDateDisabled = computed(() => {
|
||||
v-model="castTargetDate"
|
||||
class="z-300" initial-focus
|
||||
:is-date-disabled="isTargetDateDisabled"
|
||||
prevent-deselect
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@ -8,6 +8,20 @@ function jump(postion: [number, number]) {
|
||||
const getter = () => postion
|
||||
currentFocus.value = getter
|
||||
}
|
||||
function getPostionText(position: [number, number]) {
|
||||
const [x, y] = position
|
||||
function getX() {
|
||||
// add E or W
|
||||
const suffix = x < 180 ? 'E°' : 'W°'
|
||||
return `${x.toFixed(2)} ${suffix}`
|
||||
}
|
||||
function getY() {
|
||||
// add N or S
|
||||
const suffix = y < 0 ? 'S°' : 'N°'
|
||||
return `${y.toFixed(2)} ${suffix}`
|
||||
}
|
||||
return { getX, getY }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -16,7 +30,7 @@ function jump(postion: [number, number]) {
|
||||
预报台风
|
||||
</legend>
|
||||
<Table v-if="currentTcLoc">
|
||||
<TableCaption>预报台风</TableCaption>
|
||||
<!-- <TableCaption>预报台风</TableCaption> -->
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Lat </TableHead>
|
||||
@ -28,8 +42,8 @@ function jump(postion: [number, number]) {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="postion in currentTcLoc" :key="postion[0]">
|
||||
<TableCell>{{ postion[0].toFixed(2) }}</TableCell>
|
||||
<TableCell>{{ postion[1].toFixed(2) }}</TableCell>
|
||||
<TableCell>{{ getPostionText(postion).getX() }}</TableCell>
|
||||
<TableCell>{{ getPostionText(postion).getY() }}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
class="h-6 text-[13.2px]" variant="outline"
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
initial: number
|
||||
}>()
|
||||
|
||||
const { count, inc, dec } = useCounter(props.initial)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
{{ count }}
|
||||
<button class="inc" @click="inc()">
|
||||
+
|
||||
</button>
|
||||
<button class="dec" @click="dec()">
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { availableLocales, loadLanguageAsync } from '@/modules/i18n'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
async function toggleLocales() {
|
||||
// change to some real logic
|
||||
const locales = availableLocales
|
||||
const newLocale = locales[(locales.indexOf(locale.value) + 1) % locales.length]
|
||||
await loadLanguageAsync(newLocale)
|
||||
locale.value = newLocale
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav flex="~ gap-4" mt-6 justify-center text-xl>
|
||||
<RouterLink icon-btn to="/" :title="t('button.home')">
|
||||
<div i-carbon-campsite />
|
||||
</RouterLink>
|
||||
|
||||
<button icon-btn :title="t('button.toggle_dark')" @click="toggleDark()">
|
||||
<div i="carbon-sun dark:carbon-moon" />
|
||||
</button>
|
||||
|
||||
<a icon-btn :title="t('button.toggle_langs')" @click="toggleLocales()">
|
||||
<div i-carbon-language />
|
||||
</a>
|
||||
|
||||
<RouterLink icon-btn to="/about" :title="t('button.about')" data-test-id="about">
|
||||
<div i-carbon-dicom-overlay />
|
||||
</RouterLink>
|
||||
|
||||
<a icon-btn rel="noreferrer" href="https://github.com/antfu/vitesse" target="_blank" title="GitHub">
|
||||
<div i-carbon-logo-github />
|
||||
</a>
|
||||
</nav>
|
||||
</template>
|
||||
@ -2,20 +2,38 @@ import { type DateValue, parseDate } from '@internationalized/date'
|
||||
|
||||
function _useOptions() {
|
||||
const options = reactive({
|
||||
// it is inited as '', which means the date is not set
|
||||
castBeginDate: '',
|
||||
// it is inited as -1, which means the date is not set
|
||||
castDayNo: -1,
|
||||
renderMode: 'heatmap' as 'heatmap' | 'image',
|
||||
isPlaying: false,
|
||||
renderMode: 'image' as 'heatmap' | 'image',
|
||||
// maps date to path
|
||||
mapping: {} as Record<string, string>,
|
||||
|
||||
})
|
||||
const castBeginDate = ref<DateValue>()
|
||||
const castTargetDate = ref<DateValue>()
|
||||
const interval = ref(3000)
|
||||
|
||||
const currentTcLoc = ref<null | [number, number][]>(null)
|
||||
type PosGetter = () => [number, number]
|
||||
const currentFocus = ref<null | PosGetter>(null)
|
||||
|
||||
const shouldAutoNavigate = ref(true)
|
||||
|
||||
const autoPlayer = useIntervalFn(() => {
|
||||
if (options.castDayNo < 0) {
|
||||
autoPlayer.pause()
|
||||
}
|
||||
if (options.castDayNo >= 29) {
|
||||
autoPlayer.pause()
|
||||
}
|
||||
setTargetDate(castTargetDate.value!.add({ days: 1 }))
|
||||
castTargetDate.value = castTargetDate.value?.add({ days: 1 })
|
||||
}, interval, {
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
function getISOcastBeginDate() {
|
||||
const castBeginDateStr = options.castBeginDate.toString()
|
||||
if (castBeginDateStr.length !== 8) {
|
||||
@ -30,6 +48,13 @@ function _useOptions() {
|
||||
function setCastBeginDate(date: DateValue) {
|
||||
const datestr = date.toString()
|
||||
options.castBeginDate = datestr.replaceAll('-', '')
|
||||
// check if Target date is later than begin date
|
||||
// if so, set target date to begin date
|
||||
if (castTargetDate.value) {
|
||||
if (castTargetDate.value.compare(date) < 0) {
|
||||
castTargetDate.value = date.add({ days: 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setTargetDate(date: DateValue) {
|
||||
@ -44,13 +69,41 @@ function _useOptions() {
|
||||
options.castDayNo = dayNo
|
||||
return dayNo
|
||||
}
|
||||
|
||||
async function refreshCurrentTcLoc() {
|
||||
currentTcLoc.value = null
|
||||
function buildBaseQuery() {
|
||||
const q = new URLSearchParams()
|
||||
q.set('day', options.castDayNo.toString())
|
||||
const path = options.mapping[options.castBeginDate]
|
||||
q.set('path', path)
|
||||
return q
|
||||
}
|
||||
|
||||
async function getHeatPoints() {
|
||||
const q = buildBaseQuery()
|
||||
const resp = await baseFetch<{
|
||||
lat: number
|
||||
lon: number
|
||||
count: number
|
||||
}[]>(`/tc/render/heatpoint?${q}`).json()
|
||||
return resp.data.value
|
||||
}
|
||||
|
||||
async function getImageUrl() {
|
||||
const q = buildBaseQuery()
|
||||
const resp = await baseFetch(`/tc/render?${q}`).blob()
|
||||
// use URL.createObjectURL to avoid CORS issue
|
||||
const blob = await resp.data.value
|
||||
if (!blob) {
|
||||
console.error(`no image for ${q}`)
|
||||
return ''
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
return url ?? ''
|
||||
}
|
||||
|
||||
async function refreshCurrentTcLoc() {
|
||||
currentTcLoc.value = null
|
||||
const q = buildBaseQuery()
|
||||
const base = import.meta.env.VITE_BACKEND_URL
|
||||
|
||||
const url = `${base}/tc/metadata/centroids?${q}`
|
||||
@ -59,6 +112,12 @@ function _useOptions() {
|
||||
|
||||
const data = resp.data.value!
|
||||
currentTcLoc.value = data as [number, number][]
|
||||
|
||||
if (shouldAutoNavigate.value) {
|
||||
currentFocus.value = () => {
|
||||
return data[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([castBeginDate, castTargetDate], () => {
|
||||
@ -73,12 +132,16 @@ function _useOptions() {
|
||||
return {
|
||||
options,
|
||||
setCastBeginDate,
|
||||
interval,
|
||||
setTargetDate,
|
||||
getISOcastBeginDate,
|
||||
getHeatPoints,
|
||||
getImageUrl,
|
||||
currentFocus,
|
||||
castBeginDate,
|
||||
castTargetDate,
|
||||
currentTcLoc,
|
||||
autoPlayer,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
0
src/composables/utils.ts
Normal file
0
src/composables/utils.ts
Normal file
@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -11,7 +10,7 @@ const { t } = useI18n()
|
||||
<RouterView />
|
||||
<div>
|
||||
<button text-sm btn m="3 t8" @click="router.back()">
|
||||
{{ t('button.back') }}
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -1,5 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { DrawerClose, DrawerTrigger } from 'vaul-vue'
|
||||
import Colorbar from '../assets/colorbar.png'
|
||||
|
||||
const { autoPlayer, options, castBeginDate, castTargetDate } = useOptions()
|
||||
|
||||
const isPlaying = ref(false)
|
||||
|
||||
const dark = useDark()
|
||||
|
||||
function flipDark() {
|
||||
dark.value = !dark.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -8,10 +19,13 @@ import { DrawerClose, DrawerTrigger } from 'vaul-vue'
|
||||
<header
|
||||
class="sticky top-0 z-10 h-[53px] flex items-center gap-1 border-b bg-background px-4"
|
||||
>
|
||||
<span class="i-mingcute-typhoon-fill size-6 px-5" />
|
||||
<span class="i-mingcute-typhoon-fill size-6 px-5" @click="flipDark" />
|
||||
<h1 class="text-xl font-semibold">
|
||||
次季节台风预报系统
|
||||
季节内台风预报系统
|
||||
</h1>
|
||||
<div class="size-5">
|
||||
<span class="i-tabler-sun size-5" @click="flipDark" />
|
||||
</div>
|
||||
</header>
|
||||
<main
|
||||
class="grid flex-1 gap-4 overflow-auto p-4 lg:grid-cols-3 md:grid-cols-2"
|
||||
@ -23,9 +37,13 @@ import { DrawerClose, DrawerTrigger } from 'vaul-vue'
|
||||
<div
|
||||
class="relative h-full min-h-[50vh] flex flex-col rounded-xl bg-muted/50 p-4 lg:col-span-2"
|
||||
>
|
||||
<Badge variant="outline" class="absolute left-3 top-3 z-20 bg-slate-100">
|
||||
<Badge variant="outline" class="absolute left-3 top-3 z-20 bg-slate-100 dark:bg-black">
|
||||
地图
|
||||
</Badge>
|
||||
<Card v-if="options.castDayNo !== -1" class="absolute right-3 top-3 z-400 p-3">
|
||||
当前发布日期是: <br>{{ castBeginDate }}<br>
|
||||
当前目标预报日期是:<br> {{ castTargetDate }}
|
||||
</Card>
|
||||
<div class="flex-1" />
|
||||
<RouterView />
|
||||
<div
|
||||
@ -47,58 +65,83 @@ import { DrawerClose, DrawerTrigger } from 'vaul-vue'
|
||||
{{ currentTcLoc.length }} 个区域预报出台风
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<Button w-full variant="outline">
|
||||
开始自动播放
|
||||
</Button>
|
||||
<Drawer class="z-100">
|
||||
<DrawerTrigger as-child>
|
||||
<Button size="icon" variant="outline" class="w-full md:hidden">
|
||||
<span class="i-mingcute-typhoon-fill size-4" />
|
||||
<span class="">台风位置</span>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent class="z-200 max-h-[80vh] p-5">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>台风位置</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
点击可以跳转
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<PosForm />
|
||||
<DrawerClose as-child>
|
||||
<div class="py-3">
|
||||
<Button class="w-full">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerClose>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Drawer class="z-100">
|
||||
<DrawerTrigger as-child>
|
||||
<Button size="icon" class="w-full md:hidden">
|
||||
<span class="i-material-symbols-settings size-4" />
|
||||
<span class="">地图设置</span>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent class="z-200 max-h-[80vh] p-5">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>地图设置</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
选择日期和渲染方式
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<OptForm />
|
||||
<DrawerClose as-child>
|
||||
<div class="py-3">
|
||||
<Button class="w-full">
|
||||
应用
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerClose>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<div>
|
||||
<img :src="Colorbar" mx-auto max-h="60px">
|
||||
</div>
|
||||
<div grid="~ cols-2 gap-4">
|
||||
<Drawer class="z-100">
|
||||
<DrawerTrigger as-child>
|
||||
<Button size="icon" variant="outline" class="w-full md:hidden">
|
||||
<span class="i-mingcute-typhoon-fill size-4" />
|
||||
<span class="">台风列表</span>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent class="z-200 max-h-[80vh] p-5">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>台风列表</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
点击可以跳转
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<PosForm />
|
||||
<DrawerClose as-child>
|
||||
<div class="py-3">
|
||||
<Button class="w-full">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerClose>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Drawer class="z-100">
|
||||
<DrawerTrigger as-child>
|
||||
<Button size="icon" variant="outline" class="w-full md:hidden">
|
||||
<span class="i-material-symbols-settings size-4" />
|
||||
<span class="">日期设置</span>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent class="z-200 max-h-[80vh] p-5">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>地图设置</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
选择日期和渲染方式
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<OptForm />
|
||||
<DrawerClose as-child>
|
||||
<div class="py-3">
|
||||
<Button class="w-full">
|
||||
应用
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerClose>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
v-if="!isPlaying"
|
||||
:disabled="options.castDayNo === -1" w-full @click.prevent="() => {
|
||||
isPlaying = true
|
||||
autoPlayer.resume()
|
||||
}"
|
||||
>
|
||||
<span i-mdi-play />
|
||||
{{
|
||||
options.castDayNo === -1 ? "选择一个日期后可以自动播放"
|
||||
: "开始自动播放"
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
v-else w-full @click.prevent="() => {
|
||||
isPlaying = false
|
||||
autoPlayer.pause()
|
||||
}"
|
||||
>
|
||||
<span i-material-symbols-pause />
|
||||
暂停自动播放
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,6 +15,7 @@ export const createApp = ViteSSG(
|
||||
{
|
||||
routes: setupLayouts(routes),
|
||||
base: import.meta.env.BASE_URL,
|
||||
|
||||
},
|
||||
(ctx) => {
|
||||
// install all modules under `modules/`
|
||||
|
||||
@ -1,94 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import AMapClient from '@/components/AMapClient.vue'
|
||||
// import from 'vite-ssg'
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: map
|
||||
</route>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useOptions } from '@/composables/dateOpt'
|
||||
import AMapLoader from '@amap/amap-jsapi-loader'
|
||||
import '@amap/amap-jsapi-types'
|
||||
|
||||
const amapInstance = ref<null | typeof AMap>(null)
|
||||
const map = ref<null | AMap.Map>(null)
|
||||
const previousLayer = ref<null | AMap.ImageLayer>(null)
|
||||
|
||||
const apiKey = import.meta.env.VITE_AMAP_API_KEY
|
||||
|
||||
const { options, currentFocus } = useOptions()
|
||||
|
||||
watch(options, async () => {
|
||||
if (!map.value || !amapInstance.value) {
|
||||
return
|
||||
}
|
||||
if (options.castBeginDate === '' || options.castDayNo === -1) {
|
||||
return
|
||||
}
|
||||
updateMap()
|
||||
})
|
||||
|
||||
watch(currentFocus, (newFocus) => {
|
||||
if (!newFocus) {
|
||||
return
|
||||
}
|
||||
const [lat, lon] = newFocus()
|
||||
// debugger
|
||||
map.value?.setCenter([lat, lon - 35])
|
||||
map.value?.setZoom(4)
|
||||
})
|
||||
|
||||
function updateMap() {
|
||||
if (!map.value || !amapInstance.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const q = new URLSearchParams()
|
||||
q.set('day', options.castDayNo.toString())
|
||||
const path = options.mapping[options.castBeginDate]
|
||||
q.set('path', path)
|
||||
const base = import.meta.env.VITE_BACKEND_URL
|
||||
|
||||
const url = `${base}/tc/render?${q}`
|
||||
|
||||
const image = new amapInstance.value.ImageLayer({
|
||||
url,
|
||||
// @ts-expect-error AMap messed up their definition
|
||||
bounds: new AMap.Bounds([-180, -35], [180, 35]),
|
||||
// bounds: [-150, -35, 180, 35],
|
||||
zIndex: 9,
|
||||
opacity: 1,
|
||||
// zooms: [15, 20],
|
||||
})
|
||||
map.value.add(image)
|
||||
image.on('complete', () => {
|
||||
previousLayer.value?.hide()
|
||||
previousLayer.value = image
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const _AMap: typeof AMap = await AMapLoader.load({
|
||||
key: apiKey,
|
||||
version: '2.0',
|
||||
plugins: [''],
|
||||
})
|
||||
amapInstance.value = _AMap
|
||||
|
||||
map.value = new _AMap.Map('amap-container', {
|
||||
zoom: 4,
|
||||
center: [130, 17],
|
||||
zooms: [4, 20],
|
||||
// layers: [
|
||||
/* new AMap.TileLayer.Satellite() */
|
||||
// new AMap.TileLayer.RoadNet(),
|
||||
// ],
|
||||
})
|
||||
|
||||
map.value.setBounds(new AMap.Bounds([-180, -35], [180, 35]))
|
||||
map.value.setLimitBounds(new AMap.Bounds([-180, -35], [180, 35]))
|
||||
map.value.setZoom(4)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="amap-container" class="m-0 h-full w-full p-0" />
|
||||
<ClientOnly>
|
||||
<AMapClient />
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
@ -21,6 +21,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/': `${path.resolve(__dirname, 'src')}/`,
|
||||
'~/': `${path.resolve(__dirname, 'src')}/`,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user