forked from github/dataease
feat(图表): 符号地图支持自定义符号 #10408
This commit is contained in:
parent
543c1d2c96
commit
1e371bccdc
@ -318,6 +318,10 @@ declare interface ChartBasicStyle {
|
||||
* 缩放等级
|
||||
*/
|
||||
zoomLevel: number
|
||||
/**
|
||||
* 符号地图自定义符号形状
|
||||
*/
|
||||
customIcon: string
|
||||
}
|
||||
/**
|
||||
* 表头属性
|
||||
|
@ -1,18 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, PropType, reactive, watch } from 'vue'
|
||||
import { computed, onMounted, PropType, reactive, watch, ref } from 'vue'
|
||||
import {
|
||||
COLOR_PANEL,
|
||||
DEFAULT_BASIC_STYLE,
|
||||
DEFAULT_MISC
|
||||
} from '@/views/chart/components/editor/util/chart'
|
||||
import icon_info_outlined from '@/assets/svg/icon_info_outlined.svg'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import CustomColorStyleSelect from '@/views/chart/components/editor/editor-style/components/CustomColorStyleSelect.vue'
|
||||
import { cloneDeep, defaultsDeep } from 'lodash-es'
|
||||
import { cloneDeep, debounce, defaultsDeep } from 'lodash-es'
|
||||
import { SERIES_NUMBER_FIELD } from '@antv/s2'
|
||||
import { dvMainStoreWithOut } from '@/store/modules/data-visualization/dvMain'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { isNumber } from 'mathjs'
|
||||
import { ElMessage } from 'element-plus-secondary'
|
||||
import { ElMessage, UploadProps } from 'element-plus-secondary'
|
||||
import { svgStrToUrl } from '../../../js/util'
|
||||
|
||||
const dvMainStore = dvMainStoreWithOut()
|
||||
const { batchOptStatus } = storeToRefs(dvMainStore)
|
||||
@ -40,21 +42,9 @@ const state = reactive({
|
||||
fieldColumnWidth: {
|
||||
fieldId: '',
|
||||
width: 0
|
||||
}
|
||||
})
|
||||
watch(
|
||||
[
|
||||
() => props.chart.customAttr.basicStyle,
|
||||
() => props.chart.customAttr.misc,
|
||||
() => props.chart.customAttr.tableHeader,
|
||||
() => props.chart.xAxis,
|
||||
() => props.chart.yAxis
|
||||
],
|
||||
() => {
|
||||
init()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
fileList: []
|
||||
})
|
||||
const emit = defineEmits(['onBasicStyleChange', 'onMiscChange'])
|
||||
const changeBasicStyle = (prop?: string, requestData = false) => {
|
||||
emit('onBasicStyleChange', { data: state.basicStyleForm, requestData }, prop)
|
||||
@ -82,6 +72,13 @@ const init = () => {
|
||||
const basicStyle = cloneDeep(props.chart.customAttr.basicStyle)
|
||||
const miscStyle = cloneDeep(props.chart.customAttr.misc)
|
||||
configCompat(basicStyle)
|
||||
if (
|
||||
basicStyle.mapSymbol === 'custom' &&
|
||||
state.basicStyleForm.customIcon !== basicStyle.customIcon
|
||||
) {
|
||||
const file = svgStrToUrl(basicStyle.customIcon)
|
||||
file && (state.fileList[0] = { url: file })
|
||||
}
|
||||
state.basicStyleForm = defaultsDeep(basicStyle, cloneDeep(DEFAULT_BASIC_STYLE)) as ChartBasicStyle
|
||||
state.miscForm = defaultsDeep(miscStyle, cloneDeep(DEFAULT_MISC)) as ChartMiscAttr
|
||||
if (!state.customColor) {
|
||||
@ -90,6 +87,18 @@ const init = () => {
|
||||
}
|
||||
initTableColumnWidth()
|
||||
}
|
||||
const debouncedInit = debounce(init, 500)
|
||||
watch(
|
||||
[
|
||||
() => props.chart.customAttr.basicStyle,
|
||||
() => props.chart.customAttr.misc,
|
||||
() => props.chart.customAttr.tableHeader,
|
||||
() => props.chart.xAxis,
|
||||
() => props.chart.yAxis
|
||||
],
|
||||
debouncedInit,
|
||||
{ deep: true }
|
||||
)
|
||||
const configCompat = (basicStyle: ChartBasicStyle) => {
|
||||
// 悬浮改为图例和缩放按钮
|
||||
if (basicStyle.suspension === false && basicStyle.showZoom === undefined) {
|
||||
@ -230,8 +239,42 @@ const mapSymbolOptions = [
|
||||
{ name: t('chart.map_symbol_pentagon'), value: 'pentagon' },
|
||||
{ name: t('chart.map_symbol_hexagon'), value: 'hexagon' },
|
||||
{ name: t('chart.map_symbol_octagon'), value: 'octogon' },
|
||||
{ name: t('chart.line_symbol_diamond'), value: 'rhombus' }
|
||||
{ name: t('chart.line_symbol_diamond'), value: 'rhombus' },
|
||||
{ name: t('commons.custom'), value: 'custom' }
|
||||
]
|
||||
const iconUpload = ref()
|
||||
const onIconChange: UploadProps['onChange'] = async uploadFile => {
|
||||
const rawFile = uploadFile.raw
|
||||
let validIcon = true
|
||||
if (rawFile.type !== 'image/svg+xml') {
|
||||
ElMessage.error('请选择正确的 SVG 文件!')
|
||||
validIcon = false
|
||||
}
|
||||
if (rawFile.size / 1024 / 1024 > 1) {
|
||||
ElMessage.error('文件大小不能超过 1MB!')
|
||||
validIcon = false
|
||||
}
|
||||
if (!validIcon) {
|
||||
iconUpload.value?.clearFiles()
|
||||
state.fileList.splice(0)
|
||||
const svg = state.basicStyleForm.customIcon
|
||||
if (svg) {
|
||||
const file = svgStrToUrl(svg)
|
||||
file && (state.fileList[0] = { url: file })
|
||||
}
|
||||
} else {
|
||||
state.basicStyleForm.customIcon = await rawFile.text()
|
||||
changeBasicStyle('customIcon')
|
||||
}
|
||||
}
|
||||
|
||||
const changeMapSymbol = () => {
|
||||
if (state.basicStyleForm.mapSymbol === 'custom' && state.basicStyleForm.customIcon) {
|
||||
const file = svgStrToUrl(state.basicStyleForm.customIcon)
|
||||
file && (state.fileList[0] = { url: file })
|
||||
}
|
||||
changeBasicStyle('mapSymbol')
|
||||
}
|
||||
|
||||
const customSymbolicMapSizeRange = computed(() => {
|
||||
let { extBubble } = JSON.parse(JSON.stringify(props.chart))
|
||||
@ -477,11 +520,24 @@ onMounted(() => {
|
||||
<div class="map-flow-style" v-if="showProperty('symbolicMapStyle')">
|
||||
<el-row style="flex: 1">
|
||||
<el-col>
|
||||
<el-form-item :label="'符号形状'" class="form-item" :class="'form-item-' + themes">
|
||||
<el-form-item class="form-item" :class="'form-item-' + themes">
|
||||
<template v-if="state.basicStyleForm.mapSymbol === 'custom'" #label>
|
||||
<span class="data-area-label">
|
||||
<span style="margin-right: 4px">符号形状</span>
|
||||
<el-tooltip class="item" effect="dark" placement="bottom">
|
||||
<template #content>
|
||||
<div>支持 1MB 以内的 SVG 文件</div>
|
||||
</template>
|
||||
<el-icon class="hint-icon" :class="{ 'hint-icon--dark': themes === 'dark' }">
|
||||
<Icon name="icon_info_outlined"><icon_info_outlined class="svg-icon" /></Icon>
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<el-select
|
||||
:effect="themes"
|
||||
v-model="state.basicStyleForm.mapSymbol"
|
||||
@change="changeBasicStyle('mapSymbol')"
|
||||
@change="changeMapSymbol()"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in mapSymbolOptions"
|
||||
@ -493,6 +549,28 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row style="flex: 1" v-if="state.basicStyleForm.mapSymbol === 'custom'">
|
||||
<el-col>
|
||||
<el-form-item class="form-item uploader" :class="'form-item-' + themes">
|
||||
<div class="avatar-uploader-container" :class="`img-area_${themes}`">
|
||||
<el-upload
|
||||
action="#"
|
||||
accept=".svg"
|
||||
class="avatar-uploader"
|
||||
list-type="picture-card"
|
||||
ref="iconUpload"
|
||||
:effect="themes"
|
||||
:auto-upload="false"
|
||||
:file-list="state.fileList"
|
||||
:on-change="onIconChange"
|
||||
:limit="1"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="alpha-setting">
|
||||
<label class="alpha-label" :class="{ dark: 'dark' === themes }">
|
||||
{{ t('chart.size') }}
|
||||
@ -550,7 +628,7 @@ onMounted(() => {
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="alpha-setting">
|
||||
<div v-if="state.basicStyleForm.mapSymbol !== 'custom'" class="alpha-setting">
|
||||
<label class="alpha-label" :class="{ dark: 'dark' === themes }">
|
||||
{{ t('chart.not_alpha') }}
|
||||
</label>
|
||||
@ -568,7 +646,7 @@ onMounted(() => {
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="alpha-setting">
|
||||
<div v-if="state.basicStyleForm.mapSymbol !== 'custom'" class="alpha-setting">
|
||||
<label class="alpha-label" :class="{ dark: 'dark' === themes }">
|
||||
{{ t('visualization.borderWidth') }}
|
||||
</label>
|
||||
@ -1345,9 +1423,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="less">
|
||||
.form-item {
|
||||
}
|
||||
|
||||
.color-picker-style {
|
||||
cursor: pointer;
|
||||
z-index: 1003;
|
||||
@ -1424,4 +1499,99 @@ onMounted(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.avatar-uploader-container {
|
||||
:deep(.ed-upload--picture-card) {
|
||||
background: #eff0f1;
|
||||
border: 1px dashed #dee0e3;
|
||||
border-radius: 4px;
|
||||
|
||||
.ed-icon {
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.ed-icon {
|
||||
color: var(--ed-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.img-area_dark {
|
||||
:deep(.ed-upload-list__item).is-ready {
|
||||
border-color: #434343;
|
||||
}
|
||||
:deep(.ed-upload--picture-card) {
|
||||
background: #373737;
|
||||
border-color: #434343;
|
||||
.ed-icon {
|
||||
color: #ebebeb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.img-area_light {
|
||||
:deep(.ed-upload-list__item).is-ready {
|
||||
border-color: #dee0e3;
|
||||
}
|
||||
}
|
||||
:deep(.ed-upload-list__item-preview) {
|
||||
display: none !important;
|
||||
}
|
||||
:deep(.ed-upload-list__item-delete) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
:deep(.ed-upload-list__item-status-label) {
|
||||
display: none !important;
|
||||
}
|
||||
:deep(.ed-icon--close-tip) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
.avatar-uploader {
|
||||
width: 90px;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatar-uploader {
|
||||
width: 90px;
|
||||
:deep(.ed-upload) {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
line-height: 90px;
|
||||
}
|
||||
|
||||
:deep(.ed-upload-list li) {
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
}
|
||||
|
||||
:deep(.ed-upload--picture-card) {
|
||||
background: #eff0f1;
|
||||
border: 1px dashed #dee0e3;
|
||||
border-radius: 4px;
|
||||
|
||||
.ed-icon {
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.ed-icon {
|
||||
color: var(--ed-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.uploader {
|
||||
:deep(.ed-form-item__content) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.data-area-label {
|
||||
text-align: left;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
L7Wrapper
|
||||
} from '@/views/chart/components/js/panel/types/impl/l7'
|
||||
import { MAP_EDITOR_PROPERTY_INNER } from '@/views/chart/components/js/panel/charts/map/common'
|
||||
import { hexColorToRGBA, parseJson } from '@/views/chart/components/js/util'
|
||||
import { hexColorToRGBA, parseJson, svgStrToUrl } from '@/views/chart/components/js/util'
|
||||
import { deepCopy } from '@/utils/utils'
|
||||
import { GaodeMap } from '@antv/l7-maps'
|
||||
import { Scene } from '@antv/l7-scene'
|
||||
@ -101,8 +101,10 @@ export class SymbolicMap extends L7ChartView<Scene, L7Config> {
|
||||
if (basicStyle.autoFit === false) {
|
||||
center = [basicStyle.mapCenter.longitude, basicStyle.mapCenter.latitude]
|
||||
}
|
||||
// 底层
|
||||
const scene = new Scene({
|
||||
const chartObj = drawOption.chartObj as unknown as L7Wrapper<L7Config, Scene>
|
||||
let scene = chartObj?.getScene()
|
||||
if (!scene) {
|
||||
scene = new Scene({
|
||||
id: container,
|
||||
logoVisible: false,
|
||||
map: new GaodeMap({
|
||||
@ -114,6 +116,16 @@ export class SymbolicMap extends L7ChartView<Scene, L7Config> {
|
||||
showLabel: !(basicStyle.showLabel === false)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
if (scene.getLayers()?.length) {
|
||||
await scene.removeAllLayer()
|
||||
scene.setCenter(center)
|
||||
scene.setPitch(miscStyle.mapPitch)
|
||||
scene.setZoom(basicStyle.autoFit === false ? basicStyle.zoomLevel : 2.5)
|
||||
scene.setMapStyle(mapStyle)
|
||||
scene.map.showLabel = !(basicStyle.showLabel === false)
|
||||
}
|
||||
}
|
||||
mapRendering(container)
|
||||
scene.once('loaded', () => {
|
||||
mapRendered(container)
|
||||
@ -122,7 +134,7 @@ export class SymbolicMap extends L7ChartView<Scene, L7Config> {
|
||||
return new L7Wrapper(scene, undefined)
|
||||
}
|
||||
const configList: L7Config[] = []
|
||||
const symbolicLayer = this.buildSymbolicLayer(chart, basicStyle)
|
||||
const symbolicLayer = await this.buildSymbolicLayer(chart, scene)
|
||||
configList.push(symbolicLayer)
|
||||
const tooltipLayer = this.buildTooltip(chart, container, symbolicLayer)
|
||||
if (tooltipLayer) {
|
||||
@ -179,7 +191,8 @@ export class SymbolicMap extends L7ChartView<Scene, L7Config> {
|
||||
* 构建符号图层
|
||||
* @param chart
|
||||
*/
|
||||
buildSymbolicLayer = (chart, basicStyle) => {
|
||||
buildSymbolicLayer = async (chart, scene: Scene) => {
|
||||
const { basicStyle } = parseJson(chart.customAttr) as ChartAttr
|
||||
const xAxis = deepCopy(chart.xAxis)
|
||||
const xAxisExt = deepCopy(chart.xAxisExt)
|
||||
const extBubble = deepCopy(chart.extBubble)
|
||||
@ -225,10 +238,24 @@ export class SymbolicMap extends L7ChartView<Scene, L7Config> {
|
||||
y: xAxis[1].dataeaseName
|
||||
}
|
||||
})
|
||||
.shape(mapSymbol)
|
||||
.active(true)
|
||||
if (xAxisExt[0]?.dataeaseName) {
|
||||
pointLayer.color(xAxisExt[0]?.dataeaseName, colorsWithAlpha)
|
||||
if (basicStyle.mapSymbol === 'custom' && basicStyle.customIcon) {
|
||||
const parser = new DOMParser()
|
||||
for (let index = 0; index < Math.min(colorsWithAlpha.length, colorIndex + 1); index++) {
|
||||
const color = colorsWithAlpha[index]
|
||||
const fillRegex = /(fill="[^"]*")/g
|
||||
const svgStr = basicStyle.customIcon.replace(fillRegex, '')
|
||||
const doc = parser.parseFromString(svgStr, 'image/svg+xml')
|
||||
const svgEle = doc.documentElement
|
||||
svgEle.setAttribute('fill', color)
|
||||
await scene.addImage(`icon-${color}`, svgStrToUrl(svgEle.outerHTML))
|
||||
}
|
||||
pointLayer.shape('color', c => {
|
||||
return `icon-${c}`
|
||||
})
|
||||
} else {
|
||||
pointLayer.shape(mapSymbol).color(xAxisExt[0]?.dataeaseName, colorsWithAlpha)
|
||||
pointLayer.style({
|
||||
stroke: {
|
||||
field: 'color'
|
||||
@ -236,6 +263,18 @@ export class SymbolicMap extends L7ChartView<Scene, L7Config> {
|
||||
strokeWidth: mapSymbolStrokeWidth,
|
||||
opacity: mapSymbolOpacity / 10
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (basicStyle.mapSymbol === 'custom' && basicStyle.customIcon) {
|
||||
const parser = new DOMParser()
|
||||
const color = colorsWithAlpha[0]
|
||||
const fillRegex = /(fill="[^"]*")/g
|
||||
const svgStr = basicStyle.customIcon.replace(fillRegex, '')
|
||||
const doc = parser.parseFromString(svgStr, 'image/svg+xml')
|
||||
const svgEle = doc.documentElement
|
||||
svgEle.setAttribute('fill', color)
|
||||
await scene.addImage(`customIcon`, svgStrToUrl(svgEle.outerHTML))
|
||||
pointLayer.shape('customIcon')
|
||||
} else {
|
||||
pointLayer.color(colorsWithAlpha[0])
|
||||
pointLayer.style({
|
||||
@ -244,6 +283,7 @@ export class SymbolicMap extends L7ChartView<Scene, L7Config> {
|
||||
opacity: mapSymbolOpacity / 10
|
||||
})
|
||||
}
|
||||
}
|
||||
if (sizeKey) {
|
||||
pointLayer.size('size', [mapSymbolSizeMin, mapSymbolSizeMax])
|
||||
} else {
|
||||
|
@ -28,6 +28,10 @@ export class L7Wrapper<
|
||||
> extends ChartWrapper<S> {
|
||||
private readonly config: O | Array<O>
|
||||
private readonly scene: S | null = null
|
||||
|
||||
public getScene() {
|
||||
return this.scene
|
||||
}
|
||||
constructor(scene: S, l7config: O | Array<O> | undefined) {
|
||||
super()
|
||||
this.chartInstance = scene
|
||||
@ -42,6 +46,15 @@ export class L7Wrapper<
|
||||
}
|
||||
render = () => {
|
||||
if (this.scene && this.config) {
|
||||
if (this.scene.loaded) {
|
||||
if (Array.isArray(this.config)) {
|
||||
this.config?.forEach(p => {
|
||||
this.handleConfig(p)
|
||||
})
|
||||
} else {
|
||||
this.handleConfig(this.config)
|
||||
}
|
||||
} else {
|
||||
this.scene.on('loaded', () => {
|
||||
if (Array.isArray(this.config)) {
|
||||
this.config?.forEach(p => {
|
||||
@ -53,6 +66,7 @@ export class L7Wrapper<
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleConfig = (config: L7Config) => {
|
||||
if (config) {
|
||||
|
@ -1038,3 +1038,14 @@ export function convertToAlphaColor(color: string, alpha: number): string {
|
||||
}
|
||||
return 'rgba(255,255,255,1)'
|
||||
}
|
||||
|
||||
export function svgStrToUrl(svgStr: string): string {
|
||||
let file = ''
|
||||
try {
|
||||
if (svgStr) {
|
||||
const blob = new Blob([svgStr], { type: 'image/svg+xml' })
|
||||
file = URL.createObjectURL(blob)
|
||||
}
|
||||
} catch (e) {}
|
||||
return file
|
||||
}
|
||||
|
@ -299,7 +299,6 @@ let mapL7Timer: number
|
||||
const renderL7 = async (chart: ChartObj, chartView: L7ChartView<any, any>, callback) => {
|
||||
mapL7Timer && clearTimeout(mapL7Timer)
|
||||
mapL7Timer = setTimeout(async () => {
|
||||
myChart?.destroy()
|
||||
myChart = await chartView.drawChart({
|
||||
chartObj: myChart,
|
||||
container: containerId,
|
||||
|
Loading…
Reference in New Issue
Block a user