提交 8cd921f3 作者: 方治民

refactor: 重构采用 hook 模式开发 Map Widget

上级 368c2cb2
......@@ -157,6 +157,7 @@
"unocss": "^0.54.3",
"unocss-preset-weapp": "^0.54.0",
"unplugin-auto-import": "^0.16.6",
"unplugin-transform-class": "^0.5.1",
"unplugin-vue-components": "^0.25.1",
"vite": "^4.4.9",
"vue-eslint-parser": "^9.3.1"
......
......@@ -271,6 +271,9 @@ devDependencies:
unplugin-auto-import:
specifier: ^0.16.6
version: 0.16.6(@vueuse/core@10.3.0)
unplugin-transform-class:
specifier: ^0.5.1
version: 0.5.1
unplugin-vue-components:
specifier: ^0.25.1
version: 0.25.1(vue@3.2.47)
......
......@@ -282,7 +282,7 @@ export const defaultStyle: mapboxgl.Style = {
'text-max-width': 8,
},
paint: {
'text-color': '#000',
'text-color': '#333',
'text-halo-color': '#fff',
'text-halo-width': 2,
'text-halo-blur': 1,
......@@ -309,7 +309,7 @@ export const defaultStyle: mapboxgl.Style = {
'text-max-width': 8,
},
paint: {
'text-color': '#000',
'text-color': '#333',
'text-halo-color': '#fff',
'text-halo-width': 2,
'text-halo-blur': 1,
......
......@@ -32,6 +32,7 @@
container: this.id,
style: this.config?.style,
options: this.config?.options,
attribution: this.config?.attribution,
}
},
methods: {
......@@ -56,10 +57,6 @@
</template>
<style lang="less" scoped>
page {
height: 100%;
}
.wrap {
display: flex;
width: 100%;
......
......@@ -32,6 +32,7 @@ export default {
},
options?.options,
),
attributionControl: false,
})
// 绑定作用域
......@@ -62,6 +63,17 @@ export default {
center: map.getCenter(),
})
// 添加地图数据来源描述
if (options.attribution) {
map.addControl(
new mapboxgl.AttributionControl({
compact: true,
customAttribution: options.attribution?.text,
}),
options.attribution?.align || 'bottom-right',
)
}
// 加载地图控件
loadMapControl(mapboxgl, map, options?.control)
})
......
export type * from './src/types'
export * from './src/hook'
export { default as BottomBarWidget } from './src/index.vue'
import type { BottomBarInstance, BottomBarProps } from './types'
import { isProdMode } from '/@/utils/env'
/**
* 底部交互/展示小部件响应式 Hook
* @param props 组件参数
* @returns 组件响应式数据
*/
export function useBottomBarWidget<T extends BottomBarInstance>(props: BottomBarProps): [(instance: T) => void, T] {
const instanceRef = ref<Nullable<T>>(null)
const height = computed(() => instanceRef?.value?.height)
function register(instance: T) {
if (isProdMode()) {
if (instance === unref(instanceRef)) {
return
}
onUnmounted(() => {
instanceRef.value = null
})
}
instanceRef.value = instance
props && instance.setProps(props)
}
function getInstance(): T {
const instance = unref(instanceRef)
if (!instance) {
console.warn('BottomBar 小部件还未加载完成')
}
return instance as T
}
return [
register,
{
height,
setProps: (props: BottomBarProps) => {
getInstance()?.setProps(props)
},
toggleShow: (show?: boolean) => {
getInstance()?.toggleShow(show)
},
toggleExpand: (expand?: boolean) => {
getInstance()?.toggleExpand(expand)
},
} as T,
]
}
<!-- 底部 Bar 组件 -->
<script setup lang="ts">
const props = defineProps({
// 是否显示
show: {
type: Boolean,
default: true,
},
// 是否展开
expand: {
type: Boolean,
default: false,
},
// 高度
height: {
type: Number,
default: 100,
},
// 最高高度
maxHeight: {
type: Number,
default: 200,
},
})
const emits = defineEmits(['register'])
const iHeight = computed(() => `${props.height}rpx`)
const iMaxHeight = computed(() => `${props.maxHeight}rpx`)
const data = reactive({
show: props.show,
expand: props.expand,
})
function toggleShow(show?: boolean) {
data.show = show ?? !data.show
}
function toggleExpand(expand?: boolean) {
data.expand = expand ?? !data.expand
}
function setProps(value: Partial<typeof props>) {
Object.assign(data, value)
}
emits('register', {
setProps,
toggleShow,
toggleExpand,
height: computed(() => `${(data.expand ? props.maxHeight : props.height) + 60}rpx`),
})
</script>
<template>
<view class="wrap bottom-bar" :class="{ expand: data.expand }" v-show="data.show">
<view class="action" @tap="toggleExpand()">
<Icon icon="solar-double-alt-arrow-up-line-duotone" size="60" class="icon" />
</view>
<!-- 自定义内容 -->
<view class="content">
<slot></slot>
</view>
<!-- 底部安全区 -->
<fui-safe-area />
</view>
</template>
<style lang="scss" scoped>
@mixin animate() {
transition: all 0.35s;
}
.wrap {
@include animate();
position: absolute;
left: 30rpx;
bottom: 30rpx;
z-index: 99;
background-color: white;
}
.bottom-bar {
@include animate();
position: fixed;
bottom: 0;
left: 0;
padding: 30rpx;
width: calc(100% - 60rpx);
height: v-bind(iHeight);
max-height: v-bind(iMaxHeight);
box-shadow: 0 0 15rpx rgba(0, 0, 0, 0.1);
&.expand {
height: v-bind(iMaxHeight);
.icon {
transform: rotateX(180deg) !important;
}
}
.action {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
.icon {
@include animate();
position: relative;
top: -20rpx;
transform: rotateX(0);
transform-style: preserve-3d;
}
}
.content {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: calc(100% - 60rpx - 20rpx);
}
}
</style>
export interface BottomBarProps {
// 是否显示
show?: boolean
// 是否展开
expand?: boolean
// 高度 rpx
height?: number
// 最大高度 rpx
maxHeight?: number
}
export interface BottomBarInstance {
setProps: (props: BottomBarProps) => void
toggleShow: (show?: boolean) => void
toggleExpand: (expand?: boolean) => void
height?: ComputedRef<string>
}
export type * from './src/types'
export * from './src/hook'
export { default as LegendWidget } from './src/index.vue'
import type { LegendInstance, LegendProps, Option } from './types'
import { isProdMode } from '/@/utils/env'
/**
* 图例组件响应式 Hook
* @param props 组件参数
* @returns 组件响应式数据
*/
export function useLegendWidget<T extends LegendInstance>(props: LegendProps): [(instance: T) => void, T] {
const instanceRef = ref<Nullable<T>>(null)
function register(instance: T) {
if (isProdMode()) {
if (instance === unref(instanceRef)) {
return
}
onUnmounted(() => {
instanceRef.value = null
})
}
instanceRef.value = instance
props && instance?.setProps(props)
}
function getInstance(): T {
const instance = unref(instanceRef)
if (!instance) {
console.warn('图例小部件还未加载完成')
}
return instance as T
}
return [
register,
{
setProps: (props: LegendProps) => {
getInstance()?.setProps(props)
},
setTitle: (title: string) => {
getInstance()?.setTitle(title)
},
setOption: (option: Option) => {
getInstance()?.setOption(option)
},
toggleShow: (show?: boolean) => {
getInstance()?.toggleShow(show)
},
toggleExpand: (expand?: boolean) => {
getInstance()?.toggleExpand(expand)
},
} as T,
]
}
<!-- 图例组件 -->
<script setup lang="ts">
//
import { isArray } from 'lodash-es'
import { createReusableTemplate } from '@vueuse/core'
interface OptionItem {
// 颜色
color?: string
// 图标
icon?: string
// 标签
label?: string
// 多子项
items?: OptionItem[]
}
interface Option {
// 选项
items: OptionItem[] | OptionItem[][]
// label 样式
labelStyle?: Recordable
// 色块样式
blockStyle?: Recordable
}
import type { Option } from './types'
const props = defineProps({
// 显示
......@@ -47,34 +28,45 @@
},
})
const emits = defineEmits(['register'])
// 定义复用渲染组件
const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{ item: Recordable; sub?: boolean }>()
const data = reactive({
// 显示
show: props.show,
// 展开
expand: props.expand,
// 标题
title: props.title,
// JSON 数据
option: props.option,
})
watch(
() => props.show,
(show) => (data.show = show),
)
function setTitle(title: string) {
data.title = title
}
function setOption(option: Option) {
data.option = option
}
watch(
() => props.title,
(title) => (data.title = title),
)
function toggleShow(show?: boolean) {
data.show = show ?? !data.show
}
watch(
() => props.option,
(option) => (data.option = option),
{ deep: true },
)
function toggleExpand(expand?: boolean) {
data.expand = expand ?? !data.expand
}
const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{ item: Recordable; sub?: boolean }>()
function setProps(value: Partial<typeof props>) {
Object.assign(data, value)
}
emits('register', {
setProps,
setTitle,
setOption,
toggleShow,
toggleExpand,
})
</script>
<template>
......@@ -90,26 +82,7 @@
<!-- 标题 -->
<view v-if="data.title" class="title">{{ data.title }}</view>
<!-- JSON -->
<view class="option">
<template v-if="isArray(data.option.items?.[0])">
<view v-for="(item, index) in data.option.items" :key="index" class="item-wrap">
<view v-for="(subItem, subIndex) in item" :key="subIndex" class="items">
<ReuseTemplate :item="subItem" />
</view>
</view>
</template>
<template v-else>
<view class="item-wrap">
<view v-for="(item, index) in data.option.items" :key="index" class="items">
<ReuseTemplate :item="item" />
</view>
</view>
</template>
</view>
<!-- 复用渲染组件 -->
<!-- 定义复用渲染组件 -->
<DefineTemplate v-slot="{ item, sub }">
<view class="item">
<!-- 图标 -->
......@@ -137,12 +110,43 @@
</view>
</view>
</DefineTemplate>
<!-- JSON -->
<view class="option">
<template v-if="isArray(data.option.items?.[0])">
<view v-for="(item, index) in data.option.items" :key="index" class="item-wrap">
<view v-for="(subItem, subIndex) in item" :key="subIndex" class="items">
<ReuseTemplate :item="subItem" />
</view>
</view>
</template>
<template v-else>
<view class="item-wrap">
<view v-for="(item, index) in data.option.items" :key="index" class="items">
<ReuseTemplate :item="item" />
</view>
</view>
</template>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
//
@mixin animate() {
transition: all 0.35s;
}
.wrap {
@include animate();
position: absolute;
left: 30rpx;
bottom: 30rpx;
z-index: 99;
}
.legend {
display: flex;
flex-direction: column;
......@@ -158,6 +162,7 @@
justify-content: center;
align-items: center;
font-size: 26rpx;
font-weight: bold;
color: #1890ff;
.text {
......
export interface OptionItem {
// 颜色
color?: string
// 图标
icon?: string
// 标签
label?: string
// 多子项
items?: OptionItem[]
}
export interface Option {
// 选项
items: OptionItem[] | OptionItem[][]
// label 样式
labelStyle?: Recordable
// 色块样式
blockStyle?: Recordable
}
export interface LegendProps {
// 是否显示
show?: boolean
// 是否展开
expand?: boolean
// 标题
title: string
// 选项
option: Option
}
export interface LegendInstance {
setProps: (props: LegendProps) => void
setTitle: (title: string) => void
setOption: (option: Option) => void
toggleShow: (show?: boolean) => void
toggleExpand: (expand?: boolean) => void
}
export type * from './src/types'
export * from './src/hook'
export { default as SwitchControlWidget } from './src/index.vue'
import type { SwitchControlInstance, SwitchControlProps } from './types'
import { isProdMode } from '/@/utils/env'
/**
* 前后切换组件响应式 Hook
* @param props 组件参数
* @returns 组件响应式数据
*/
export function useSwitchControlWidget<T extends SwitchControlInstance>(
props: SwitchControlProps,
): [(instance: T) => void, T] {
const instanceRef = ref<Nullable<T>>(null)
function register(instance: T) {
if (isProdMode()) {
if (instance === unref(instanceRef)) {
return
}
onUnmounted(() => {
instanceRef.value = null
})
}
instanceRef.value = instance
props && instance?.setProps(props)
}
function getInstance(): T {
const instance = unref(instanceRef)
if (!instance) {
console.warn('前后切换小部件还未加载完成')
}
return instance as T
}
return [
register,
{
setProps: (props: SwitchControlProps) => {
getInstance()?.setProps(props)
},
toggleShow: (show?: boolean) => {
getInstance()?.toggleShow(show)
},
prev: () => {
getInstance()?.prev()
},
next: () => {
getInstance()?.next()
},
} as T,
]
}
<!-- 前后切换组件 -->
<script setup lang="ts">
const props = defineProps({
// 是否显示
show: {
type: Boolean,
default: true,
},
})
const emits = defineEmits(['register', 'prev', 'next'])
const data = reactive({
show: props.show,
// 往前
prev: () => {},
// 往后
next: () => {},
})
function toggleShow(show?: boolean) {
data.show = show ?? !data.show
}
function setProps(value: Partial<typeof props>) {
Object.assign(data, value)
}
function onPrev() {
data.prev?.()
emits('prev')
}
function onNext() {
data.next?.()
emits('next')
}
emits('register', {
prev: onPrev,
next: onNext,
setProps,
toggleShow,
})
</script>
<template>
<view class="wrap switch-control">
<view class="prev" @tap="onPrev">
<Icon class="icon" icon="ic-baseline-arrow-back-ios" size="50" color="white" />
</view>
<view class="next" @tap="onNext">
<Icon class="icon" icon="ic-baseline-arrow-forward-ios" size="50" color="white" />
</view>
</view>
</template>
<style lang="scss" scoped>
.switch-control {
> * {
width: 70rpx;
height: 120rpx;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: calc(50% - 100rpx);
z-index: 99;
}
.prev {
left: 0;
border-radius: 0 10rpx 10rpx 0;
.icon {
position: relative;
left: 12rpx;
}
}
.next {
right: 0;
border-radius: 10rpx 0 0 10rpx;
}
}
</style>
export interface SwitchControlProps {
// 是否显示
show?: boolean
// 上一个
prev?: () => void
// 下一个
next?: () => void
}
export interface SwitchControlInstance {
prev: () => void
next: () => void
setProps: (props: SwitchControlProps) => void
toggleShow: (show?: boolean) => void
}
......@@ -77,7 +77,9 @@
"titleNView": {
"buttons": [
{
"type": "share"
"type": "share",
"color": "white",
"width": "50px"
}
]
}
......
<!-- 底部 Bar 组件 -->
<script setup lang="ts">
//
</script>
<template>
<view class="wrap bottom-bar">
<!-- -->
</view>
</template>
<style lang="scss" scoped>
//
</style>
<!-- 前后切换组件 -->
<script setup lang="ts">
//
</script>
<template>
<view class="wrap switch-control">
<!-- -->
</view>
</template>
<style lang="scss" scoped>
//
</style>
......@@ -2,7 +2,7 @@
// 单色块图例配置
export const defaultLegendConfig = {
expand: true,
expand: false,
title: '单位: mm',
option: {
blockStyle: {
......
<!-- 页面组件 -->
<script setup lang="ts">
// 组件
import Legend from './components/Legend.vue'
import { defaultLegendConfig } from './config'
import { useShare } from '@/hooks/page/useShare'
import { LegendWidget, useLegendWidget } from '@/components/Map/Mapbox/widgets/Legend'
import { BottomBarWidget, useBottomBarWidget } from '@/components/Map/Mapbox/widgets/BottomBar'
import { SwitchControlWidget, useSwitchControlWidget } from '@/components/Map/Mapbox/widgets/SwitchControl'
useShare()
const { expand, title, option } = defaultLegendConfig
const config = {
// 测试地图数据来源标注展示
attribution: {
text: '湖南省气象台',
// align: 'bottom-left',
},
}
// 前后切换小部件
const [registerSwitchControlWidget] = useSwitchControlWidget({
show: true,
prev: () => console.log('prev'),
next: () => console.log('next'),
})
// 图例小部件
const [registerLegendWidget] = useLegendWidget({
show: true,
expand: true,
title: defaultLegendConfig.title,
option: defaultLegendConfig.option,
})
// 底部 Bar 小部件
const [registerBottomBarWidget, { height }] = useBottomBarWidget({
show: true,
expand: true,
height: 100,
maxHeight: 200,
})
</script>
<template>
<view class="bg-white h-100vh">
<view class="bg-white h-100vh rain">
<!-- 地图组件 -->
<Mapbox style="height: 100%" />
<Mapbox :config="config" />
<!-- 交互组件集 -->
<view class="interaction">
<view class="widgets">
<!-- -->
<!-- Legend 组件 -->
<Legend :expand="expand" :title="title" :option="option" />
<!-- 前后切换小部件 -->
<SwitchControlWidget @register="registerSwitchControlWidget" />
<!-- 图例小部件 -->
<LegendWidget @register="registerLegendWidget" />
<!-- 底部 Bar 小部件 -->
<BottomBarWidget @register="registerBottomBarWidget">
<!-- 内容 Slot -->
<view class="c-coolGray">底部交互控件/展示内容</view>
</BottomBarWidget>
</view>
</view>
</template>
<style lang="scss" scoped>
//
.interaction {
.widgets {
:deep(.legend) {
position: absolute;
left: 30rpx;
bottom: 30rpx;
z-index: 99;
bottom: calc(30rpx + v-bind(height));
// 如果底部使用 bottom-left attribution 则需要加上 80rpx,默认显示在右侧,不会冲突
// bottom: calc(30rpx + 80rpx + v-bind(height));
}
}
</style>
<style>
/* 外部套一层防止全局污染 */
.rain {
:deep(.mapboxgl-ctrl-bottom-right) {
bottom: calc(30rpx + 60rpx + v-bind(height));
}
:deep(.mapboxgl-ctrl-bottom-left) {
bottom: calc(30rpx + 60rpx + v-bind(height));
}
}
</style>
......@@ -106,4 +106,28 @@
/* stylelint-disable-next-line function-no-unknown */
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
transition: all 0.35s;
}
.mapboxgl-ctrl-attrib-inner {
display: inline-block !important;
vertical-align: middle;
line-height: 18px;
font-size: 12px;
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
color: rgba(0, 0, 0, 0.75);
text-decoration: none;
}
.mapboxgl-ctrl-attrib.mapboxgl-compact {
padding-right: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.mapboxgl-ctrl-attrib-button {
position: initial;
}
......@@ -7,11 +7,15 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
BottomBar: typeof import('./../src/components/Map/Mapbox/components/BottomBar.vue')['default']
BottomBarWidget: typeof import('./../src/components/Map/Mapbox/widgets/BottomBarWidget.vue')['default']
CacheImage: typeof import('./../src/components/CacheImage/index.vue')['default']
CustomPicker: typeof import('./../src/components/CustomPicker/index.vue')['default']
Empty: typeof import('./../src/components/Empty/index.vue')['default']
FDragItem: typeof import('./../src/components/FirstUI/fui-drag/f-drag-item.vue')['default']
FIndexListItem: typeof import('./../src/components/FirstUI/fui-index-list/f-index-list-item.vue')['default']
FloatFillter: typeof import('./../src/components/Map/Mapbox/components/FloatFillter.vue')['default']
FloatFillterWidget: typeof import('./../src/components/Map/Mapbox/widgets/FloatFillterWidget.vue')['default']
FuiActionsheet: typeof import('./../src/components/FirstUI/fui-actionsheet/fui-actionsheet.vue')['default']
FuiAlert: typeof import('./../src/components/FirstUI/fui-alert/fui-alert.vue')['default']
FuiAnimation: typeof import('./../src/components/FirstUI/fui-animation/fui-animation.vue')['default']
......@@ -136,11 +140,20 @@ declare module 'vue' {
Grid: typeof import('./../src/components/Layout/Grid.vue')['default']
Header: typeof import('./../src/components/Layout/Header.vue')['default']
Icon: typeof import('./../src/components/Icon/index.vue')['default']
LeftBar: typeof import('./../src/components/Map/Mapbox/components/LeftBar.vue')['default']
LeftBarWidget: typeof import('./../src/components/Map/Mapbox/widgets/LeftBarWidget.vue')['default']
Legend: typeof import('./../src/components/Map/Mapbox/components/Legend.vue')['default']
LegendWidget: typeof import('./../src/components/Map/Mapbox/components/LegendWidget/index.vue')['default']
Mapbox: typeof import('./../src/components/Map/Mapbox/index.vue')['default']
MenuHeader: typeof import('./../src/components/Layout/MenuHeader.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Src: typeof import('./../src/components/Map/Mapbox/widgets/BottomBar/src/index.vue')['default']
SwitchControl: typeof import('./../src/components/Map/Mapbox/components/SwitchControl.vue')['default']
SwitchControlWidget: typeof import('./../src/components/Map/Mapbox/widgets/SwitchControlWidget.vue')['default']
ThumbnailPreview: typeof import('./../src/components/ThumbnailPreview/index.vue')['default']
TopBar: typeof import('./../src/components/Map/Mapbox/components/TopBar.vue')['default']
TopBarWidget: typeof import('./../src/components/Map/Mapbox/widgets/TopBarWidget.vue')['default']
View: typeof import('./../src/components/Layout/View.vue')['default']
}
}
......@@ -2,6 +2,8 @@ import type { AxiosRequestConfig } from 'axios'
declare global {
type Recordable<T = any> = Record<string, T>
type Nullable<T> = T | null
type NonNullable<T> = T extends null | undefined ? never : T
interface ImportMetaEnv extends ViteEnv {
__: unknown
......
......@@ -8,7 +8,19 @@ const { presetWeappAttributify, transformerAttributify } = extractorAttributify(
attributes: [...defaultAttributes, 'icon'],
})
const ICONS = [
'ic-baseline-keyboard-arrow-up',
'ic-baseline-keyboard-arrow-down',
'ic-baseline-keyboard-arrow-left',
'ic-baseline-keyboard-arrow-right',
'ic-baseline-arrow-back-ios',
'ic-baseline-arrow-forward-ios',
'solar-double-alt-arrow-up-line-duotone',
'solar-double-alt-arrow-down-line-duotone',
]
export default defineConfig({
safelist: ICONS.map((icon) => `icon-${icon.replace(/:/g, '-')}`),
shortcuts: [
{
bg: 'bg-[#F4F5F7]',
......@@ -23,9 +35,10 @@ export default defineConfig({
presetWeappAttributify(),
// icon
presetIcons({
prefix: 'icon-',
prefix: ['i-', 'icon-'],
extraProperties: {
display: 'inline-flex',
'vertical-align': 'middle',
},
}),
],
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论