From e8b17419765ff797bd1bcd9c2cf8de97f190be7c Mon Sep 17 00:00:00 2001 From: AntoineYANG Date: Tue, 4 Jul 2023 10:17:04 +0800 Subject: [PATCH 01/15] feat: leaflet geojson support Co-Authored-By: kyusho --- packages/graphic-walker/package.json | 3 + .../graphic-walker/src/components/callout.tsx | 2 +- .../components/leafletRenderer/encodings.ts | 69 +++++++++ .../src/components/leafletRenderer/index.tsx | 134 ++++++++++++++++++ .../graphic-walker/src/components/tooltip.tsx | 2 +- packages/graphic-walker/src/config.ts | 3 +- .../src/fields/aestheticFields.tsx | 2 + .../src/fields/fieldsContext.tsx | 2 + .../src/fields/posFields/index.tsx | 2 + packages/graphic-walker/src/index.tsx | 49 ++----- packages/graphic-walker/src/interfaces.ts | 5 + .../graphic-walker/src/locales/en-US.json | 7 +- .../graphic-walker/src/locales/ja-JP.json | 7 +- .../graphic-walker/src/locales/zh-CN.json | 7 +- .../src/renderer/pureRenderer.tsx | 23 +-- .../src/renderer/specRenderer.tsx | 63 ++++---- packages/graphic-walker/src/shadow-dom.tsx | 56 ++++++++ .../src/store/visualSpecStore.ts | 2 + .../src/visualSettings/index.tsx | 4 +- 19 files changed, 364 insertions(+), 78 deletions(-) create mode 100644 packages/graphic-walker/src/components/leafletRenderer/encodings.ts create mode 100644 packages/graphic-walker/src/components/leafletRenderer/index.tsx create mode 100644 packages/graphic-walker/src/shadow-dom.tsx diff --git a/packages/graphic-walker/package.json b/packages/graphic-walker/package.json index 47637542..812d4724 100644 --- a/packages/graphic-walker/package.json +++ b/packages/graphic-walker/package.json @@ -42,6 +42,7 @@ "i18next": "^21.9.1", "i18next-browser-languagedetector": "^6.1.5", "immer": "^9.0.15", + "leaflet": "^1.9.4", "mobx": "^6.3.3", "mobx-react-lite": "^3.2.1", "nanoid": "^4.0.2", @@ -49,6 +50,7 @@ "postinstall-postinstall": "^2.1.0", "re-resizable": "^6.9.8", "react-i18next": "^11.18.6", + "react-leaflet": "^4.2.1", "react-shadow": "^20.0.0", "rxjs": "^7.3.0", "tailwindcss": "^3.2.4", @@ -59,6 +61,7 @@ }, "devDependencies": { "@rollup/plugin-typescript": "^8.2.5", + "@types/leaflet": "^1.9.3", "@types/react": "^17.x", "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.x", diff --git a/packages/graphic-walker/src/components/callout.tsx b/packages/graphic-walker/src/components/callout.tsx index 1a43f94f..5401589d 100644 --- a/packages/graphic-walker/src/components/callout.tsx +++ b/packages/graphic-walker/src/components/callout.tsx @@ -2,7 +2,7 @@ import React, { memo, ReactNode, useContext, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import styled from "styled-components"; import type { IDarkMode } from "../interfaces"; -import { ShadowDomContext } from ".."; +import { ShadowDomContext } from "../shadow-dom"; import { useCurrentMediaTheme } from "../utils/media"; export interface CalloutProps { diff --git a/packages/graphic-walker/src/components/leafletRenderer/encodings.ts b/packages/graphic-walker/src/components/leafletRenderer/encodings.ts new file mode 100644 index 00000000..6d9d73cb --- /dev/null +++ b/packages/graphic-walker/src/components/leafletRenderer/encodings.ts @@ -0,0 +1,69 @@ +import { useCallback, useMemo } from "react"; +import type { IRow, IViewField, VegaGlobalConfig } from "../../interfaces"; +import { getMeaAggKey } from "../../utils"; + + +export interface Scale { + (record: IRow): T; +} + +// export const useColorScale = (data: IRow[], field: IViewField | null | undefined, vegaConfig: VegaGlobalConfig): Scale => { +// if (!field) { +// return; +// } +// if (field.semanticType === 'quantitative' || field.semanticType === 'temporal') { +// // continuous + +// } +// if ('scale' in vegaConfig) { +// vegaConfig.scale?.continuousPadding +// } + +// }; + +const MIN_SIZE = 1; +const MAX_SIZE = 10; +const DEFAULT_SIZE = 3; + +export const useSizeScale = (data: IRow[], field: IViewField | null | undefined, defaultAggregate: boolean): Scale => { + const key = useMemo(() => { + if (!field) { + return ''; + } + if (defaultAggregate && field.aggName && field.analyticType === 'measure') { + return getMeaAggKey(field.fid, field.aggName); + } + return field.fid; + }, [field, defaultAggregate]); + + const [domainMin, domainMax] = useMemo(() => { + if (!key) { + return [0, 0]; + } + const values = data.map((row) => Number(row[key])).filter((val) => !isNaN(val)); + if (values.length === 0) { + return [0, 0]; + } + return values.slice(1).reduce<[number, number]>((acc, val) => { + if (val < acc[0]) { + acc[0] = val; + } + if (val > acc[1]) { + acc[1] = val; + } + return acc; + }, [values[0], values[0]]); + }, [key, data]); + + return useCallback(function SizeScale (record: IRow): number { + if (!key) { + return DEFAULT_SIZE; + } + const val = Number(record[key]); + if (isNaN(val)) { + return 0; + } + const size = (val - domainMin) / (domainMax - domainMin); + return MIN_SIZE + size * (MAX_SIZE - MIN_SIZE); + }, [key, domainMin, domainMax, defaultAggregate]); +}; diff --git a/packages/graphic-walker/src/components/leafletRenderer/index.tsx b/packages/graphic-walker/src/components/leafletRenderer/index.tsx new file mode 100644 index 00000000..ee4b3d4b --- /dev/null +++ b/packages/graphic-walker/src/components/leafletRenderer/index.tsx @@ -0,0 +1,134 @@ +import React, { forwardRef, useEffect, useMemo, useRef } from "react"; +import { MapContainer, TileLayer, Tooltip, CircleMarker } from "react-leaflet"; +import type { Map } from "leaflet"; +import type { DeepReadonly, DraggableFieldState, IRow, IVisualConfig, VegaGlobalConfig } from "../../interfaces"; +import { useSizeScale } from "./encodings"; + + +export interface ILeafletRendererProps { + vegaConfig: VegaGlobalConfig; + draggableFieldState: DeepReadonly; + visualConfig: DeepReadonly; + data: IRow[]; +} + +export interface ILeafletRendererRef {} + +const isValidLatLng = (latRaw: unknown, lngRaw: unknown) => { + const lat = Number(latRaw); + const lng = Number(lngRaw); + return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; +}; + +const formatCoerceLatLng = (latRaw: unknown, lngRaw: unknown) => { + return `${ + typeof latRaw === 'number' ? latRaw : JSON.stringify(latRaw) + }, ${ + typeof lngRaw === 'number' ? lngRaw : JSON.stringify(lngRaw) + }`; +}; + +const debugMaxLen = 20; + +const LeafletRenderer = forwardRef(function LeafletRenderer (props, ref) { + const { draggableFieldState, data, visualConfig } = props; + const { latitude: [lat], longitude: [lng], details, dimensions, measures, size, color, shape, opacity, text } = draggableFieldState; + const { defaultAggregated } = visualConfig; + const allFields = useMemo(() => [...dimensions, ...measures], [dimensions, measures]); + const latField = useMemo(() => allFields.find((f) => f.geoRole === 'latitude'), [allFields]); + const lngField = useMemo(() => allFields.find((f) => f.geoRole === 'longitude'), [allFields]); + const latitude = useMemo(() => lat ?? latField, [lat, latField]); + const longitude = useMemo(() => lng ?? lngField, [lng, lngField]); + + // TODO: web worker + const lngLat = useMemo<[lat: number, lng: number][]>(() => { + if (longitude && latitude) { + return data.map<[lat: number, lng: number]>(row => [Number(row[latitude.fid]), Number(row[longitude.fid])]).filter(v => isValidLatLng(v[0], v[1])); + } + return []; + }, [longitude, latitude, data]); + + const [bounds, center] = useMemo<[bounds: [[n: number, w: number], [s: number, e: number]], center: [lng: number, lat: number]]>(() => { + if (lngLat.length > 0) { + const [bounds, coords] = lngLat.reduce<[bounds: [[w: number, n: number], [e: number, s: number]], center: [lat: number, lng: number]]>(([bounds, acc], [lat, lng]) => { + if (lng < bounds[0][0]) { + bounds[0][0] = lng; + } + if (lng > bounds[1][0]) { + bounds[1][0] = lng; + } + if (lat < bounds[0][1]) { + bounds[0][1] = lat; + } + if (lat > bounds[1][1]) { + bounds[1][1] = lat; + } + return [bounds, [acc[0] + lng, acc[1] + lat]]; + }, [[[-180, -90], [180, 90]], [0, 0]]); + return [bounds, [coords[0] / lngLat.length, coords[1] / lngLat.length] as [number, number]]; + } + + return [[[-180, -90], [180, 90]], [0, 0]]; + }, [lngLat]); + + const failedLatLngListRef = useRef<[index: number, lng: unknown, lat: unknown][]>([]); + failedLatLngListRef.current = []; + + useEffect(() => { + if (failedLatLngListRef.current.length > 0) { + console.warn(`Failed to render ${failedLatLngListRef.current.length.toLocaleString()} markers of ${data.length.toLocaleString()} rows due to invalid lat/lng.\n--------\n${ + `${failedLatLngListRef.current.slice(0, debugMaxLen).map(([idx, lng, lat]) => + `[${idx + 1}] ${formatCoerceLatLng(lat, lng)}` + ).join('\n')}` + + (failedLatLngListRef.current.length > debugMaxLen ? `\n\t... and ${(failedLatLngListRef.current.length - debugMaxLen).toLocaleString()} more` : '') + }\n`); + } + }); + + const mapRef = useRef(null); + + useEffect(() => { + mapRef.current?.flyToBounds(bounds); + }, [`${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`]); + + const sizeScale = useSizeScale(data, size[0], defaultAggregated); + + const tooltipFields = useMemo(() => { + return details.map((det) => det.fid).concat( + [size, color, shape, opacity, text].map((enc) => enc[0]).filter(Boolean).map((enc) => enc.fid) + ); + }, [details, size, color, shape, opacity, text]); + + const getFieldName = (fid: string) => allFields.find((f) => f.fid === fid)?.name ?? fid; + + return ( + + + {Boolean(latitude && longitude) && data.map((row, i) => { + const lat = row[latitude.fid]; + const lng = row[longitude.fid]; + if (!isValidLatLng(lat, lng)) { + failedLatLngListRef.current.push([i, lat, lng]); + return null; + } + return ( + + {tooltipFields.length > 0 && ( + + {tooltipFields.map((fid, j) => ( +

{getFieldName(fid)}: {row[fid]}

+ ))} +
+ )} +
+ ); + })} +
+ ); +}); + + +export default LeafletRenderer; diff --git a/packages/graphic-walker/src/components/tooltip.tsx b/packages/graphic-walker/src/components/tooltip.tsx index e61be15e..74ed486d 100644 --- a/packages/graphic-walker/src/components/tooltip.tsx +++ b/packages/graphic-walker/src/components/tooltip.tsx @@ -2,7 +2,7 @@ import React, { memo, useContext, useEffect, useMemo, useRef, useState } from "r import { createPortal } from "react-dom"; import styled from "styled-components"; import type { IDarkMode } from "../interfaces"; -import { ShadowDomContext } from ".."; +import { ShadowDomContext } from "../shadow-dom"; import { useCurrentMediaTheme } from "../utils/media"; export interface TooltipProps { diff --git a/packages/graphic-walker/src/config.ts b/packages/graphic-walker/src/config.ts index fa4c8731..8480765a 100644 --- a/packages/graphic-walker/src/config.ts +++ b/packages/graphic-walker/src/config.ts @@ -13,7 +13,8 @@ export const GEMO_TYPES: Readonly = [ 'arc', 'text', 'boxplot', - 'table' + 'table', + 'map', ] as const; export const STACK_MODE: Readonly = [ diff --git a/packages/graphic-walker/src/fields/aestheticFields.tsx b/packages/graphic-walker/src/fields/aestheticFields.tsx index 27ec766e..0743e815 100644 --- a/packages/graphic-walker/src/fields/aestheticFields.tsx +++ b/packages/graphic-walker/src/fields/aestheticFields.tsx @@ -26,6 +26,8 @@ const AestheticFields: React.FC = props => { return aestheticFields.filter(f => f.id === 'text' || f.id === 'color' || f.id === 'size' || f.id === 'opacity'); case 'table': return [] + case 'map': + return aestheticFields; default: return aestheticFields.filter(f => f.id !== 'text'); } diff --git a/packages/graphic-walker/src/fields/fieldsContext.tsx b/packages/graphic-walker/src/fields/fieldsContext.tsx index 32f2a18d..a4d55213 100644 --- a/packages/graphic-walker/src/fields/fieldsContext.tsx +++ b/packages/graphic-walker/src/fields/fieldsContext.tsx @@ -48,6 +48,8 @@ export const DRAGGABLE_STATE_KEYS: Readonly = [ { id: 'shape', mode: 1}, { id: 'theta', mode: 1 }, { id: 'radius', mode: 1 }, + { id: 'longitude', mode: 1 }, + { id: 'latitude', mode: 1 }, { id: 'filters', mode: 1 }, { id: 'details', mode: 1 }, { id: 'text', mode: 1 }, diff --git a/packages/graphic-walker/src/fields/posFields/index.tsx b/packages/graphic-walker/src/fields/posFields/index.tsx index dfb380d5..1f6b9c2d 100644 --- a/packages/graphic-walker/src/fields/posFields/index.tsx +++ b/packages/graphic-walker/src/fields/posFields/index.tsx @@ -14,6 +14,8 @@ const PosFields: React.FC = props => { const channels = useMemo(() => { if (geoms[0] === 'arc') { return DRAGGABLE_STATE_KEYS.filter(f => f.id === 'radius' || f.id === 'theta'); + } else if (geoms[0] === 'map') { + return DRAGGABLE_STATE_KEYS.filter(f => f.id === 'longitude' || f.id === 'latitude'); } return DRAGGABLE_STATE_KEYS.filter(f => f.id === 'columns' || f.id === 'rows'); }, [geoms[0]]) diff --git a/packages/graphic-walker/src/index.tsx b/packages/graphic-walker/src/index.tsx index 0aaa4229..dab71623 100644 --- a/packages/graphic-walker/src/index.tsx +++ b/packages/graphic-walker/src/index.tsx @@ -1,51 +1,32 @@ -import React, { createContext, useEffect, useRef, useState } from "react"; -import { StyleSheetManager } from "styled-components"; -import root from "react-shadow"; +import React from "react"; import { DOM } from "@kanaries/react-beautiful-dnd"; import { observer } from "mobx-react-lite"; import App, { IGWProps } from "./App"; import { StoreWrapper } from "./store/index"; import { FieldsContextWrapper } from "./fields/fieldsContext"; +import { ShadowDom } from "./shadow-dom"; import "./empty_sheet.css"; -import tailwindStyle from "tailwindcss/tailwind.css?inline"; -import style from "./index.css?inline"; - -export const ShadowDomContext = createContext<{ root: ShadowRoot | null }>({ root: null }); export const GraphicWalker: React.FC = observer((props) => { - const [shadowRoot, setShadowRoot] = useState(null); - const rootRef = useRef(null); const { storeRef } = props; - useEffect(() => { - if (rootRef.current) { - const shadowRoot = rootRef.current.shadowRoot!; - setShadowRoot(shadowRoot); - DOM.setBody(shadowRoot); - DOM.setHead(shadowRoot); - return () => { - DOM.setBody(document.body); - DOM.setHead(document.head); - }; - } - }, []); + const handleMount = (shadowRoot: ShadowRoot) => { + DOM.setBody(shadowRoot); + DOM.setHead(shadowRoot); + }; + const handleUnmount = () => { + DOM.setBody(document.body); + DOM.setHead(document.head); + }; return ( - - - - {shadowRoot && ( - - - - - - - - )} - + + + + + ); }); diff --git a/packages/graphic-walker/src/interfaces.ts b/packages/graphic-walker/src/interfaces.ts index 71270a9e..5dea64c1 100644 --- a/packages/graphic-walker/src/interfaces.ts +++ b/packages/graphic-walker/src/interfaces.ts @@ -72,6 +72,8 @@ export interface IExpression { as: string; } +export type IGeoRole = 'longitude' | 'latitude' | 'none'; + export interface IField { /** * fid: key in data record @@ -87,6 +89,7 @@ export interface IField { aggName?: string; semanticType: ISemanticType; analyticType: IAnalyticType; + geoRole?: IGeoRole; cmp?: (a: any, b: any) => number; computed?: boolean; expression?: IExpression; @@ -162,6 +165,8 @@ export interface DraggableFieldState { shape: IViewField[]; theta: IViewField[]; radius: IViewField[]; + longitude: IViewField[]; + latitude: IViewField[]; details: IViewField[]; filters: IFilterField[]; text: IViewField[]; diff --git a/packages/graphic-walker/src/locales/en-US.json b/packages/graphic-walker/src/locales/en-US.json index 4f3513a4..64284aab 100644 --- a/packages/graphic-walker/src/locales/en-US.json +++ b/packages/graphic-walker/src/locales/en-US.json @@ -31,7 +31,8 @@ "arc": "Arc", "boxplot": "Box (Box Plot)", "table": "Table", - "text": "Text" + "text": "Text", + "map": "Map" }, "stack_mode": { "__enum__": "Stack Mode", @@ -68,7 +69,9 @@ "radius": "Radius", "filters": "Filters", "details": "Details", - "text": "Text" + "text": "Text", + "longitude": "Longitude", + "latitude": "Latitude" }, "aggregator": { "sum": "Sum", diff --git a/packages/graphic-walker/src/locales/ja-JP.json b/packages/graphic-walker/src/locales/ja-JP.json index 8aec3d76..ebff3c60 100644 --- a/packages/graphic-walker/src/locales/ja-JP.json +++ b/packages/graphic-walker/src/locales/ja-JP.json @@ -31,7 +31,8 @@ "arc": "アーク", "boxplot": "ボックスプロット", "table": "表", - "text": "本文" + "text": "テキスト", + "map": "地図" }, "stack_mode": { "__enum__": "スタックモード", @@ -67,7 +68,9 @@ "theta": "角度", "radius": "半径", "filters": "フィルター", - "text": "本文" + "text": "本文", + "longitude": "経度", + "latitude": "緯度" }, "aggregator": { "sum": "合計", diff --git a/packages/graphic-walker/src/locales/zh-CN.json b/packages/graphic-walker/src/locales/zh-CN.json index 13adeb26..4e6d0c6b 100644 --- a/packages/graphic-walker/src/locales/zh-CN.json +++ b/packages/graphic-walker/src/locales/zh-CN.json @@ -31,7 +31,8 @@ "arc": "弧形", "boxplot": "统计箱", "table": "表格", - "text": "文本" + "text": "文本", + "map": "地图" }, "layout_type": { "__enum__": "尺寸模式", @@ -68,7 +69,9 @@ "radius": "半径", "filters": "筛选器", "details": "信息", - "text": "文本" + "text": "文本", + "longitude": "经度", + "latitude": "纬度" }, "aggregator": { "sum": "求和", diff --git a/packages/graphic-walker/src/renderer/pureRenderer.tsx b/packages/graphic-walker/src/renderer/pureRenderer.tsx index c9676dce..c83ec86c 100644 --- a/packages/graphic-walker/src/renderer/pureRenderer.tsx +++ b/packages/graphic-walker/src/renderer/pureRenderer.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, forwardRef, useMemo, useRef } from 'react'; import { unstable_batchedUpdates } from 'react-dom'; import { toJS } from 'mobx'; import { observer } from 'mobx-react-lite'; +import { ShadowDom } from '../shadow-dom'; import type { IDarkMode, IViewField, IRow, IThemeKey, DraggableFieldState, IVisualConfig } from '../interfaces'; import type { IReactVegaHandler } from '../vis/react-vega'; import SpecRenderer from './specRenderer'; @@ -75,15 +76,19 @@ const PureRenderer = forwardRef(function }, [waiting]); return ( - + +
+ +
+
); }); diff --git a/packages/graphic-walker/src/renderer/specRenderer.tsx b/packages/graphic-walker/src/renderer/specRenderer.tsx index 56a02fd5..1b0a8df2 100644 --- a/packages/graphic-walker/src/renderer/specRenderer.tsx +++ b/packages/graphic-walker/src/renderer/specRenderer.tsx @@ -3,6 +3,7 @@ import { Resizable } from 're-resizable'; import React, { forwardRef, useMemo } from 'react'; import PivotTable from '../components/pivotTable'; +import LeafletRenderer from '../components/leafletRenderer'; import ReactVega, { IReactVegaHandler } from '../vis/react-vega'; import { DeepReadonly, DraggableFieldState, IDarkMode, IRow, IThemeKey, IVisualConfig, VegaGlobalConfig } from '../interfaces'; import LoadingLayer from '../components/loadingLayer'; @@ -95,6 +96,8 @@ const SpecRenderer = forwardRef(function ( ); } + const isSpatial = geoms[0] === 'map'; + return ( (function ( }} > {loading && } - + {isSpatial && ( + + )} + {isSpatial || ( + + )} ); }); diff --git a/packages/graphic-walker/src/shadow-dom.tsx b/packages/graphic-walker/src/shadow-dom.tsx new file mode 100644 index 00000000..d4281043 --- /dev/null +++ b/packages/graphic-walker/src/shadow-dom.tsx @@ -0,0 +1,56 @@ +import React, { HTMLAttributes, createContext, useEffect, useRef, useState } from "react"; +import { StyleSheetManager } from "styled-components"; +import root from "react-shadow"; + +import "./empty_sheet.css"; +import tailwindStyle from "tailwindcss/tailwind.css?inline"; +import style from "./index.css?inline"; + +export const ShadowDomContext = createContext<{ root: ShadowRoot | null }>({ root: null }); + +interface IShadowDomProps extends HTMLAttributes { + onMount?: (shadowRoot: ShadowRoot) => void; + onUnmount?: () => void; +} + +export const ShadowDom: React.FC = function ShadowDom ({ onMount, onUnmount, children, ...attrs }) { + const [shadowRoot, setShadowRoot] = useState(null); + const rootRef = useRef(null); + + const onMountRef = useRef(onMount); + onMountRef.current = onMount; + const onUnmountRef = useRef(onUnmount); + onUnmountRef.current = onUnmount; + + useEffect(() => { + if (rootRef.current) { + const shadowRoot = rootRef.current.shadowRoot!; + setShadowRoot(shadowRoot); + onMountRef.current?.(shadowRoot); + return () => { + onUnmountRef.current?.(); + }; + } + }, []); + + return ( + + + + {/* Leaflet CSS file */} + + {shadowRoot && ( + + + {children} + + + )} + + ); +}; diff --git a/packages/graphic-walker/src/store/visualSpecStore.ts b/packages/graphic-walker/src/store/visualSpecStore.ts index 3b6cf176..254f2f17 100644 --- a/packages/graphic-walker/src/store/visualSpecStore.ts +++ b/packages/graphic-walker/src/store/visualSpecStore.ts @@ -56,6 +56,8 @@ export function initEncoding(): DraggableFieldState { shape: [], radius: [], theta: [], + longitude: [], + latitude: [], details: [], filters: [], text: [], diff --git a/packages/graphic-walker/src/visualSettings/index.tsx b/packages/graphic-walker/src/visualSettings/index.tsx index a9814f23..5d7d74cb 100644 --- a/packages/graphic-walker/src/visualSettings/index.tsx +++ b/packages/graphic-walker/src/visualSettings/index.tsx @@ -19,6 +19,7 @@ import { LightBulbIcon, CodeBracketSquareIcon, Cog6ToothIcon, + MapIcon, } from '@heroicons/react/24/outline'; import { observer } from 'mobx-react-lite'; import React, { SVGProps, useCallback, useMemo } from 'react'; @@ -161,7 +162,8 @@ const VisualSettings: React.FC = ({ rendererHandler, darkModePr text: (props: SVGProps) => , arc: (props: SVGProps) => , boxplot: (props: SVGProps) => , - table: (props: SVGProps) => + table: (props: SVGProps) => , + map: MapIcon, }[g], })), value: markType, From 0aa4595b043f700d2d65e6607009393dcd3b80bb Mon Sep 17 00:00:00 2001 From: AntoineYANG Date: Tue, 4 Jul 2023 17:41:56 +0800 Subject: [PATCH 02/15] feat(map): scale support Co-Authored-By: kyusho --- packages/graphic-walker/package.json | 2 + .../components/leafletRenderer/encodings.ts | 151 ++++++++++++++++-- .../src/components/leafletRenderer/index.tsx | 47 ++++-- .../src/fields/aestheticFields.tsx | 2 +- .../src/store/visualSpecStore.ts | 4 + yarn.lock | 71 +++++++- 6 files changed, 250 insertions(+), 27 deletions(-) diff --git a/packages/graphic-walker/package.json b/packages/graphic-walker/package.json index 812d4724..c17e3ca8 100644 --- a/packages/graphic-walker/package.json +++ b/packages/graphic-walker/package.json @@ -39,6 +39,7 @@ "@kanaries/react-beautiful-dnd": "0.0.1", "@kanaries/web-data-loader": "^0.1.7", "autoprefixer": "^10.3.5", + "d3-scale": "^4.0.2", "i18next": "^21.9.1", "i18next-browser-languagedetector": "^6.1.5", "immer": "^9.0.15", @@ -61,6 +62,7 @@ }, "devDependencies": { "@rollup/plugin-typescript": "^8.2.5", + "@types/d3-scale": "^4.0.3", "@types/leaflet": "^1.9.3", "@types/react": "^17.x", "@types/react-beautiful-dnd": "^13.1.2", diff --git a/packages/graphic-walker/src/components/leafletRenderer/encodings.ts b/packages/graphic-walker/src/components/leafletRenderer/encodings.ts index 6d9d73cb..25c232a3 100644 --- a/packages/graphic-walker/src/components/leafletRenderer/encodings.ts +++ b/packages/graphic-walker/src/components/leafletRenderer/encodings.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { scaleLinear, scaleOrdinal } from "d3-scale"; import type { IRow, IViewField, VegaGlobalConfig } from "../../interfaces"; import { getMeaAggKey } from "../../utils"; @@ -7,21 +8,97 @@ export interface Scale { (record: IRow): T; } -// export const useColorScale = (data: IRow[], field: IViewField | null | undefined, vegaConfig: VegaGlobalConfig): Scale => { -// if (!field) { -// return; -// } -// if (field.semanticType === 'quantitative' || field.semanticType === 'temporal') { -// // continuous +const DEFAULT_COLOR = "#5B8FF9"; +const DEFAULT_COLOR_STEP_1 = "#EBCCFF"; +const DEFAULT_COLOR_STEP_2 = "#0D1090"; +const DEFAULT_SCHEME_CATEGORY = [ + "#5B8FF9", + "#61DDAA", + "#65789B", + "#F6BD16", + "#7262FD", + "#78D3F8", + "#9661BC", + "#F6903D", + "#008685", + "#F08BB4", +]; -// } -// if ('scale' in vegaConfig) { -// vegaConfig.scale?.continuousPadding -// } +export const useColorScale = (data: IRow[], field: IViewField | null | undefined, defaultAggregate: boolean, vegaConfig: VegaGlobalConfig): Scale => { + const color = (vegaConfig as any).circle?.fill || DEFAULT_COLOR; + const fixedScale = useCallback(function ColorScale (row: IRow) { + return color; + }, [color]); + const colorRange = useMemo(() => { + if ('scale' in vegaConfig && typeof vegaConfig.scale === 'object' && 'continuous' in vegaConfig.scale) { + if (Array.isArray((vegaConfig.scale?.continuous as any).range)) { + return ((vegaConfig.scale?.continuous as any).range as string[]).slice(0, 2); + } + } + return [DEFAULT_COLOR_STEP_1, DEFAULT_COLOR_STEP_2]; + }, [vegaConfig]); + const schemeCategory = useMemo(() => { + if (Array.isArray(vegaConfig.range?.category)) { + return vegaConfig.range!.category as string[]; + } + return DEFAULT_SCHEME_CATEGORY; + }, [vegaConfig]); + const key = useMemo(() => { + if (!field) { + return ''; + } + if (defaultAggregate && field.aggName && field.analyticType === 'measure') { + return getMeaAggKey(field.fid, field.aggName); + } + return field.fid; + }, [field, defaultAggregate]); + const domain = useMemo<[number, number]>(() => { + if (!field || field.semanticType === 'nominal') { + return [0, 0]; + } + return data.reduce((dom: [number, number], { [key]: cur }) => { + if (cur < dom[0]) { + dom[0] = cur; + } + if (cur > dom[1]) { + dom[1] = cur; + } + return dom; + }, [Infinity, -Infinity]); + }, [data, field, key]); + const distributions = useMemo(() => { + if (!field || field.semanticType !== 'nominal') { + return []; + } + return [...data.reduce((set: Set, row) => { + set.add(row[key]); + return set; + }, new Set())]; + }, [data, field, key]); + const continuousScale = useMemo(() => { + const scale = scaleLinear().domain(domain).range(colorRange); + return function ColorScale (row: IRow) { + return scale(Number(row[key])); + }; + }, [domain, key, colorRange]); + const discreteScale = useMemo(() => { + const scale = scaleOrdinal().domain(distributions).range(schemeCategory); + return function ColorScale (row: IRow) { + return scale(row[key]); + }; + }, [distributions, schemeCategory]); -// }; + if (!field) { + return fixedScale; + } + if (field.semanticType === 'quantitative' || field.semanticType === 'temporal') { + // continuous + return continuousScale; + } + return discreteScale; +}; -const MIN_SIZE = 1; +const MIN_SIZE = 2; const MAX_SIZE = 10; const DEFAULT_SIZE = 3; @@ -64,6 +141,54 @@ export const useSizeScale = (data: IRow[], field: IViewField | null | undefined, return 0; } const size = (val - domainMin) / (domainMax - domainMin); - return MIN_SIZE + size * (MAX_SIZE - MIN_SIZE); + return MIN_SIZE + Math.sqrt(size) * (MAX_SIZE - MIN_SIZE); + }, [key, domainMin, domainMax, defaultAggregate]); +}; + + +const MIN_OPACITY = 0.33; +const MAX_OPACITY = 1.0; +const DEFAULT_OPACITY = 1; + +export const useOpacityScale = (data: IRow[], field: IViewField | null | undefined, defaultAggregate: boolean): Scale => { + const key = useMemo(() => { + if (!field) { + return ''; + } + if (defaultAggregate && field.aggName && field.analyticType === 'measure') { + return getMeaAggKey(field.fid, field.aggName); + } + return field.fid; + }, [field, defaultAggregate]); + + const [domainMin, domainMax] = useMemo(() => { + if (!key) { + return [0, 0]; + } + const values = data.map((row) => Number(row[key])).filter((val) => !isNaN(val)); + if (values.length === 0) { + return [0, 0]; + } + return values.slice(1).reduce<[number, number]>((acc, val) => { + if (val < acc[0]) { + acc[0] = val; + } + if (val > acc[1]) { + acc[1] = val; + } + return acc; + }, [values[0], values[0]]); + }, [key, data]); + + return useCallback(function OpacityScale (record: IRow): number { + if (!key) { + return DEFAULT_OPACITY; + } + const val = Number(record[key]); + if (isNaN(val)) { + return 0; + } + const size = (val - domainMin) / (domainMax - domainMin); + return MIN_OPACITY + size * (MAX_OPACITY - MIN_OPACITY); }, [key, domainMin, domainMax, defaultAggregate]); }; diff --git a/packages/graphic-walker/src/components/leafletRenderer/index.tsx b/packages/graphic-walker/src/components/leafletRenderer/index.tsx index ee4b3d4b..2f5708dd 100644 --- a/packages/graphic-walker/src/components/leafletRenderer/index.tsx +++ b/packages/graphic-walker/src/components/leafletRenderer/index.tsx @@ -2,7 +2,8 @@ import React, { forwardRef, useEffect, useMemo, useRef } from "react"; import { MapContainer, TileLayer, Tooltip, CircleMarker } from "react-leaflet"; import type { Map } from "leaflet"; import type { DeepReadonly, DraggableFieldState, IRow, IVisualConfig, VegaGlobalConfig } from "../../interfaces"; -import { useSizeScale } from "./encodings"; +import { getMeaAggKey } from "../../utils"; +import { useColorScale, useOpacityScale, useSizeScale } from "./encodings"; export interface ILeafletRendererProps { @@ -31,7 +32,7 @@ const formatCoerceLatLng = (latRaw: unknown, lngRaw: unknown) => { const debugMaxLen = 20; const LeafletRenderer = forwardRef(function LeafletRenderer (props, ref) { - const { draggableFieldState, data, visualConfig } = props; + const { draggableFieldState, data, visualConfig, vegaConfig } = props; const { latitude: [lat], longitude: [lng], details, dimensions, measures, size, color, shape, opacity, text } = draggableFieldState; const { defaultAggregated } = visualConfig; const allFields = useMemo(() => [...dimensions, ...measures], [dimensions, measures]); @@ -92,17 +93,25 @@ const LeafletRenderer = forwardRef(f }, [`${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`]); const sizeScale = useSizeScale(data, size[0], defaultAggregated); + const opacityScale = useOpacityScale(data, opacity[0], defaultAggregated); + const colorScale = useColorScale(data, color[0], defaultAggregated, vegaConfig); const tooltipFields = useMemo(() => { - return details.map((det) => det.fid).concat( - [size, color, shape, opacity, text].map((enc) => enc[0]).filter(Boolean).map((enc) => enc.fid) - ); - }, [details, size, color, shape, opacity, text]); - - const getFieldName = (fid: string) => allFields.find((f) => f.fid === fid)?.name ?? fid; + return details.concat( + [size, color, shape, opacity, text].map((enc) => enc[0]).filter(Boolean) + ).map(f => ({ + ...f, + key: defaultAggregated && f.analyticType === 'measure' && f.aggName ? getMeaAggKey(f.fid, f.aggName) : f.fid, + })); + }, [defaultAggregated, details, size, color, shape, opacity, text]); + + const getFieldName = (fid: string, aggName: string | undefined) => { + const name = allFields.find((f) => f.fid === fid)?.name ?? fid; + return aggName ? `${aggName}(${name})` : name; + }; return ( - + (f failedLatLngListRef.current.push([i, lat, lng]); return null; } + const radius = sizeScale(row); + const opacity = opacityScale(row); + const color = colorScale(row); return ( - + {tooltipFields.length > 0 && ( - {tooltipFields.map((fid, j) => ( -

{getFieldName(fid)}: {row[fid]}

+ {tooltipFields.map(({ fid, aggName, key }, j) => ( +

{getFieldName(fid, aggName)}: {row[key]}

))}
)} diff --git a/packages/graphic-walker/src/fields/aestheticFields.tsx b/packages/graphic-walker/src/fields/aestheticFields.tsx index 0743e815..763bc641 100644 --- a/packages/graphic-walker/src/fields/aestheticFields.tsx +++ b/packages/graphic-walker/src/fields/aestheticFields.tsx @@ -27,7 +27,7 @@ const AestheticFields: React.FC = props => { case 'table': return [] case 'map': - return aestheticFields; + return aestheticFields.filter(f => f.id === 'color' || f.id === 'opacity' || f.id === 'size' || f.id === 'details'); default: return aestheticFields.filter(f => f.id !== 'text'); } diff --git a/packages/graphic-walker/src/store/visualSpecStore.ts b/packages/graphic-walker/src/store/visualSpecStore.ts index 254f2f17..1ece6331 100644 --- a/packages/graphic-walker/src/store/visualSpecStore.ts +++ b/packages/graphic-walker/src/store/visualSpecStore.ts @@ -497,6 +497,10 @@ export class VizSpecStore { encodings.columns = encodings.rows; encodings.rows = fieldsInCup as typeof encodings.rows; // assume this as writable + + const fieldsInCup2 = encodings.longitude; + encodings.longitude = encodings.latitude; + encodings.latitude = fieldsInCup2 as typeof encodings.latitude; // assume this as writable }); } public createBinField(stateKey: keyof DraggableFieldState, index: number, binType: 'bin' | 'binCount') { diff --git a/yarn.lock b/yarn.lock index 1972705b..d961850c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -443,6 +443,34 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@kanaries/graphic-walker@0.3.6": + version "0.3.6" + resolved "https://registry.npmmirror.com/@kanaries/graphic-walker/-/graphic-walker-0.3.6.tgz#f80779a01a72f86690f13cab5807eaa868eb62ea" + integrity sha512-kBgKA5NKQbQKhg2KFDUqjnzMVW9ePP1c5tWn9nkvTj4ffWHfe1ci3UrIqErX7bN7n7IEfqYoJk30b1HCQZXruw== + dependencies: + "@headlessui/react" "^1.7.12" + "@heroicons/react" "^2.0.8" + "@kanaries/react-beautiful-dnd" "0.0.1" + "@kanaries/web-data-loader" "^0.1.7" + autoprefixer "^10.3.5" + i18next "^21.9.1" + i18next-browser-languagedetector "^6.1.5" + immer "^9.0.15" + mobx "^6.3.3" + mobx-react-lite "^3.2.1" + nanoid "^4.0.2" + postcss "^8.3.7" + postinstall-postinstall "^2.1.0" + re-resizable "^6.9.8" + react-i18next "^11.18.6" + react-shadow "^20.0.0" + rxjs "^7.3.0" + tailwindcss "^3.2.4" + uuid "^8.3.2" + vega "^5.22.1" + vega-embed "^6.21.0" + vega-lite "^5.6.0" + "@kanaries/react-beautiful-dnd@0.0.1": version "0.0.1" resolved "https://registry.yarnpkg.com/@kanaries/react-beautiful-dnd/-/react-beautiful-dnd-0.0.1.tgz#18369ecd87649ff3d939ef0559e0e38f848de0b3" @@ -485,6 +513,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@react-leaflet/core@^2.1.0": + version "2.1.0" + resolved "https://registry.npmmirror.com/@react-leaflet/core/-/core-2.1.0.tgz#383acd31259d7c9ae8fb1b02d5e18fe613c2a13d" + integrity sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg== + "@rollup/plugin-typescript@^8.2.5": version "8.5.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-8.5.0.tgz#7ea11599a15b0a30fa7ea69ce3b791d41b862515" @@ -572,6 +605,18 @@ resolved "https://registry.yarnpkg.com/@types/clone/-/clone-2.1.1.tgz#9b880d0ce9b1f209b5e0bd6d9caa38209db34024" integrity sha512-BZIU34bSYye0j/BFcPraiDZ5ka6MJADjcDVELGf7glr9K+iE8NYVjFslJFVWzskSxkLLyCrSPScE82/UUoBSvg== +"@types/d3-scale@^4.0.3": + version "4.0.3" + resolved "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.3.tgz#7a5780e934e52b6f63ad9c24b105e33dd58102b5" + integrity sha512-PATBiMCpvHJSMtZAMEhc2WyL+hnzarKzI6wAHYjhsonjWJYGq5BXTzQjv4l8m2jO183/4wZ90rKvSeT7o72xNQ== + dependencies: + "@types/d3-time" "*" + +"@types/d3-time@*": + version "3.0.0" + resolved "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819" + integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" @@ -582,6 +627,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83" integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw== +"@types/geojson@*": + version "7946.0.10" + resolved "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249" + integrity sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA== + "@types/hoist-non-react-statics@*", "@types/hoist-non-react-statics@^3.3.0": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" @@ -595,6 +645,13 @@ resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.7.tgz#226a9e31680835a6188e887f3988e60c04d3f6a3" integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA== +"@types/leaflet@^1.9.3": + version "1.9.3" + resolved "https://registry.npmmirror.com/@types/leaflet/-/leaflet-1.9.3.tgz#7aac302189eb3aa283f444316167995df42a5467" + integrity sha512-Caa1lYOgKVqDkDZVWkto2Z5JtVo09spEaUt2S69LiugbBpoqQu92HYFMGUbYezZbnBkyOxMNPXHSgRrRY5UyIA== + dependencies: + "@types/geojson" "*" + "@types/node@*", "@types/node@^18.7.10": version "18.14.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.1.tgz#90dad8476f1e42797c49d6f8b69aaf9f876fc69f" @@ -1043,7 +1100,7 @@ d3-path@^3.0.1, d3-path@^3.1.0: d3-scale@^4.0.2: version "4.0.2" - resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + resolved "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== dependencies: d3-array "2.10.0 - 3" @@ -1539,6 +1596,11 @@ json5@^2.2.2: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +leaflet@^1.9.4: + version "1.9.4" + resolved "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" + integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA== + lilconfig@^2.0.5, lilconfig@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" @@ -1822,6 +1884,13 @@ react-is@^17.0.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-leaflet@^4.2.1: + version "4.2.1" + resolved "https://registry.npmmirror.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780" + integrity sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q== + dependencies: + "@react-leaflet/core" "^2.1.0" + react-redux@^7.2.0: version "7.2.9" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" From c2efe9136653c8c89985ad0ccb781e4705ea36e1 Mon Sep 17 00:00:00 2001 From: AntoineYANG Date: Tue, 4 Jul 2023 18:59:29 +0800 Subject: [PATCH 03/15] feat: coord system Co-Authored-By: kyusho --- .../src/components/leafletRenderer/index.tsx | 6 +-- packages/graphic-walker/src/config.ts | 44 ++++++++++++------- .../src/fields/aestheticFields.tsx | 3 +- .../src/fields/fieldsContext.tsx | 1 + .../src/fields/posFields/index.tsx | 12 +++-- packages/graphic-walker/src/interfaces.ts | 5 +++ .../graphic-walker/src/locales/en-US.json | 11 ++++- .../graphic-walker/src/locales/ja-JP.json | 11 ++++- .../graphic-walker/src/locales/zh-CN.json | 11 ++++- .../src/renderer/pureRenderer.tsx | 31 +++++++++---- .../src/renderer/specRenderer.tsx | 4 +- .../src/store/visualSpecStore.ts | 5 ++- .../src/visualSettings/index.tsx | 35 ++++++++++++--- 13 files changed, 130 insertions(+), 49 deletions(-) diff --git a/packages/graphic-walker/src/components/leafletRenderer/index.tsx b/packages/graphic-walker/src/components/leafletRenderer/index.tsx index 2f5708dd..fdfee48a 100644 --- a/packages/graphic-walker/src/components/leafletRenderer/index.tsx +++ b/packages/graphic-walker/src/components/leafletRenderer/index.tsx @@ -7,7 +7,7 @@ import { useColorScale, useOpacityScale, useSizeScale } from "./encodings"; export interface ILeafletRendererProps { - vegaConfig: VegaGlobalConfig; + vegaConfig?: VegaGlobalConfig; draggableFieldState: DeepReadonly; visualConfig: DeepReadonly; data: IRow[]; @@ -32,7 +32,7 @@ const formatCoerceLatLng = (latRaw: unknown, lngRaw: unknown) => { const debugMaxLen = 20; const LeafletRenderer = forwardRef(function LeafletRenderer (props, ref) { - const { draggableFieldState, data, visualConfig, vegaConfig } = props; + const { draggableFieldState, data, visualConfig, vegaConfig = {} } = props; const { latitude: [lat], longitude: [lng], details, dimensions, measures, size, color, shape, opacity, text } = draggableFieldState; const { defaultAggregated } = visualConfig; const allFields = useMemo(() => [...dimensions, ...measures], [dimensions, measures]); @@ -111,7 +111,7 @@ const LeafletRenderer = forwardRef(f }; return ( - + = [ - 'auto', - 'bar', - 'line', - 'area', - 'trail', - 'point', - 'circle', - 'tick', - 'rect', - 'arc', - 'text', - 'boxplot', - 'table', - 'map', -] as const; +export const GEMO_TYPES: Record> = { + generic: [ + 'auto', + 'bar', + 'line', + 'area', + 'trail', + 'point', + 'circle', + 'tick', + 'rect', + 'arc', + 'text', + 'boxplot', + 'table', + ], + geographic: [ + 'poi', + 'geoshape', + ], +} as const; + +export const COORD_TYPES: Readonly = [ + 'generic', + 'geographic', +]; export const STACK_MODE: Readonly = [ 'none', diff --git a/packages/graphic-walker/src/fields/aestheticFields.tsx b/packages/graphic-walker/src/fields/aestheticFields.tsx index 763bc641..d61b8352 100644 --- a/packages/graphic-walker/src/fields/aestheticFields.tsx +++ b/packages/graphic-walker/src/fields/aestheticFields.tsx @@ -26,7 +26,8 @@ const AestheticFields: React.FC = props => { return aestheticFields.filter(f => f.id === 'text' || f.id === 'color' || f.id === 'size' || f.id === 'opacity'); case 'table': return [] - case 'map': + case 'poi': + case 'geoshape': return aestheticFields.filter(f => f.id === 'color' || f.id === 'opacity' || f.id === 'size' || f.id === 'details'); default: return aestheticFields.filter(f => f.id !== 'text'); diff --git a/packages/graphic-walker/src/fields/fieldsContext.tsx b/packages/graphic-walker/src/fields/fieldsContext.tsx index a4d55213..5aa3ffe1 100644 --- a/packages/graphic-walker/src/fields/fieldsContext.tsx +++ b/packages/graphic-walker/src/fields/fieldsContext.tsx @@ -50,6 +50,7 @@ export const DRAGGABLE_STATE_KEYS: Readonly = [ { id: 'radius', mode: 1 }, { id: 'longitude', mode: 1 }, { id: 'latitude', mode: 1 }, + { id: 'geoId', mode: 1 }, { id: 'filters', mode: 1 }, { id: 'details', mode: 1 }, { id: 'text', mode: 1 }, diff --git a/packages/graphic-walker/src/fields/posFields/index.tsx b/packages/graphic-walker/src/fields/posFields/index.tsx index 1f6b9c2d..9d4df4cf 100644 --- a/packages/graphic-walker/src/fields/posFields/index.tsx +++ b/packages/graphic-walker/src/fields/posFields/index.tsx @@ -9,16 +9,20 @@ import OBFieldContainer from '../obComponents/obFContainer'; const PosFields: React.FC = props => { const { vizStore } = useGlobalStore(); const { visualConfig } = vizStore; - const { geoms } = visualConfig; + const { geoms, coordSystem = 'generic' } = visualConfig; const channels = useMemo(() => { + if (coordSystem === 'geographic') { + if (geoms[0] === 'geoshape') { + return DRAGGABLE_STATE_KEYS.filter(f => f.id === 'geoId'); + } + return DRAGGABLE_STATE_KEYS.filter(f => f.id === 'longitude' || f.id === 'latitude'); + } if (geoms[0] === 'arc') { return DRAGGABLE_STATE_KEYS.filter(f => f.id === 'radius' || f.id === 'theta'); - } else if (geoms[0] === 'map') { - return DRAGGABLE_STATE_KEYS.filter(f => f.id === 'longitude' || f.id === 'latitude'); } return DRAGGABLE_STATE_KEYS.filter(f => f.id === 'columns' || f.id === 'rows'); - }, [geoms[0]]) + }, [geoms[0], coordSystem]) return
{ channels.map(dkey => diff --git a/packages/graphic-walker/src/interfaces.ts b/packages/graphic-walker/src/interfaces.ts index 5dea64c1..04ac3296 100644 --- a/packages/graphic-walker/src/interfaces.ts +++ b/packages/graphic-walker/src/interfaces.ts @@ -167,6 +167,7 @@ export interface DraggableFieldState { radius: IViewField[]; longitude: IViewField[]; latitude: IViewField[]; + geoId: IViewField[]; details: IViewField[]; filters: IFilterField[]; text: IViewField[]; @@ -193,9 +194,13 @@ export type IFilterRule = export type IStackMode = 'none' | 'stack' | 'normalize'; +export type ICoordMode = 'generic' | 'geographic'; + export interface IVisualConfig { defaultAggregated: boolean; geoms: string[]; + /** @default "generic" */ + coordSystem?: ICoordMode; stack: IStackMode; showActions: boolean; interactiveScale: boolean; diff --git a/packages/graphic-walker/src/locales/en-US.json b/packages/graphic-walker/src/locales/en-US.json index 64284aab..b334e0fb 100644 --- a/packages/graphic-walker/src/locales/en-US.json +++ b/packages/graphic-walker/src/locales/en-US.json @@ -32,7 +32,13 @@ "boxplot": "Box (Box Plot)", "table": "Table", "text": "Text", - "map": "Map" + "poi": "POI", + "geoshape": "Geo Shape" + }, + "coord_system": { + "__enum__": "Coordinate System", + "generic": "Generic", + "geographic": "Geographic" }, "stack_mode": { "__enum__": "Stack Mode", @@ -71,7 +77,8 @@ "details": "Details", "text": "Text", "longitude": "Longitude", - "latitude": "Latitude" + "latitude": "Latitude", + "geoId": "Geometry ID" }, "aggregator": { "sum": "Sum", diff --git a/packages/graphic-walker/src/locales/ja-JP.json b/packages/graphic-walker/src/locales/ja-JP.json index ebff3c60..137a9113 100644 --- a/packages/graphic-walker/src/locales/ja-JP.json +++ b/packages/graphic-walker/src/locales/ja-JP.json @@ -32,7 +32,13 @@ "boxplot": "ボックスプロット", "table": "表", "text": "テキスト", - "map": "地図" + "poi": "POI", + "geoshape": "形状" + }, + "coord_system": { + "__enum__": "座標系", + "generic": "ジェネリック", + "geographic": "地理" }, "stack_mode": { "__enum__": "スタックモード", @@ -70,7 +76,8 @@ "filters": "フィルター", "text": "本文", "longitude": "経度", - "latitude": "緯度" + "latitude": "緯度", + "geoId": "地理ID" }, "aggregator": { "sum": "合計", diff --git a/packages/graphic-walker/src/locales/zh-CN.json b/packages/graphic-walker/src/locales/zh-CN.json index 4e6d0c6b..8047fb6b 100644 --- a/packages/graphic-walker/src/locales/zh-CN.json +++ b/packages/graphic-walker/src/locales/zh-CN.json @@ -32,7 +32,13 @@ "boxplot": "统计箱", "table": "表格", "text": "文本", - "map": "地图" + "poi": "兴趣点", + "geoshape": "地理形状" + }, + "coord_system": { + "__enum__": "坐标系统", + "generic": "通用", + "geographic": "地理" }, "layout_type": { "__enum__": "尺寸模式", @@ -71,7 +77,8 @@ "details": "信息", "text": "文本", "longitude": "经度", - "latitude": "纬度" + "latitude": "纬度", + "geoId": "地理 ID" }, "aggregator": { "sum": "求和", diff --git a/packages/graphic-walker/src/renderer/pureRenderer.tsx b/packages/graphic-walker/src/renderer/pureRenderer.tsx index c83ec86c..1ee96536 100644 --- a/packages/graphic-walker/src/renderer/pureRenderer.tsx +++ b/packages/graphic-walker/src/renderer/pureRenderer.tsx @@ -3,6 +3,7 @@ import { unstable_batchedUpdates } from 'react-dom'; import { toJS } from 'mobx'; import { observer } from 'mobx-react-lite'; import { ShadowDom } from '../shadow-dom'; +import LeafletRenderer from '../components/leafletRenderer'; import type { IDarkMode, IViewField, IRow, IThemeKey, DraggableFieldState, IVisualConfig } from '../interfaces'; import type { IReactVegaHandler } from '../vis/react-vega'; import SpecRenderer from './specRenderer'; @@ -75,18 +76,30 @@ const PureRenderer = forwardRef(function } }, [waiting]); + const { coordSystem = 'generic' } = visualConfig; + const isSpatial = coordSystem === 'geographic'; + return (
- + {isSpatial && ( + + )} + {isSpatial || ( + + )}
); diff --git a/packages/graphic-walker/src/renderer/specRenderer.tsx b/packages/graphic-walker/src/renderer/specRenderer.tsx index 1b0a8df2..31777703 100644 --- a/packages/graphic-walker/src/renderer/specRenderer.tsx +++ b/packages/graphic-walker/src/renderer/specRenderer.tsx @@ -29,7 +29,7 @@ const SpecRenderer = forwardRef(function ( ref ) { // const { draggableFieldState, visualConfig } = vizStore; - const { geoms, interactiveScale, defaultAggregated, stack, showActions, size, format: _format, zeroScale } = visualConfig; + const { geoms, coordSystem = 'generic', interactiveScale, defaultAggregated, stack, showActions, size, format: _format, zeroScale } = visualConfig; const rows = draggableFieldState.rows; const columns = draggableFieldState.columns; @@ -96,7 +96,7 @@ const SpecRenderer = forwardRef(function ( ); } - const isSpatial = geoms[0] === 'map'; + const isSpatial = coordSystem === 'geographic'; return ( = ({ rendererHandler, darkModePr const { t } = useTranslation('translation', { keyPrefix: 'main.tabpanel.settings' }); const { - defaultAggregated, geoms: [markType], stack, interactiveScale, size: { mode: sizeMode, width, height }, + defaultAggregated, geoms: [markType], coordSystem = 'generic', stack, interactiveScale, size: { mode: sizeMode, width, height }, showActions, } = visualConfig; @@ -146,7 +148,7 @@ const VisualSettings: React.FC = ({ rendererHandler, darkModePr color: 'rgb(294,115,22)', }, }, - options: GEMO_TYPES.map(g => ({ + options: GEMO_TYPES[coordSystem].map(g => ({ key: g, label: tGlobal(`constant.mark_type.${g}`), icon: { @@ -163,7 +165,8 @@ const VisualSettings: React.FC = ({ rendererHandler, darkModePr arc: (props: SVGProps) => , boxplot: (props: SVGProps) => , table: (props: SVGProps) => , - map: MapIcon, + poi: MapPinIcon, + geoshape: RectangleGroupIcon, }[g], })), value: markType, @@ -253,6 +256,26 @@ const VisualSettings: React.FC = ({ rendererHandler, darkModePr ), }, '-', + { + key: 'coord_system', + label: tGlobal('constant.coord_system.__enum__'), + icon: StopIcon, + options: COORD_TYPES.map(c => ({ + key: c, + label: tGlobal(`constant.coord_system.${c}`), + icon: { + generic: (props: SVGProps) => , + geographic: GlobeAltIcon, + }[c], + })), + value: coordSystem, + onSelect: value => { + const coord = value as typeof COORD_TYPES[number]; + vizStore.setVisualConfig('coordSystem', coord); + vizStore.setVisualConfig('geoms', [GEMO_TYPES[coord][0]]); + }, + }, + '-', { key: 'debug', label: t('toggle.debug'), @@ -313,7 +336,7 @@ const VisualSettings: React.FC = ({ rendererHandler, darkModePr } return items; - }, [vizStore, canUndo, canRedo, defaultAggregated, markType, stack, interactiveScale, sizeMode, width, height, showActions, downloadPNG, downloadSVG, dark, extra, exclude]); + }, [vizStore, canUndo, canRedo, defaultAggregated, markType, coordSystem, stack, interactiveScale, sizeMode, width, height, showActions, downloadPNG, downloadSVG, dark, extra, exclude]); return
Date: Tue, 4 Jul 2023 19:12:01 +0800 Subject: [PATCH 04/15] fix: missing marks on container resize This commit resolves a bug where map markers were not being rendered correctly when the map container was resized. This was achieved by invoking the 'invalidateSize' method on the map instance whenever the container size changed. Co-Authored-By: kyusho --- .../src/components/leafletRenderer/index.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/graphic-walker/src/components/leafletRenderer/index.tsx b/packages/graphic-walker/src/components/leafletRenderer/index.tsx index fdfee48a..25f53fba 100644 --- a/packages/graphic-walker/src/components/leafletRenderer/index.tsx +++ b/packages/graphic-walker/src/components/leafletRenderer/index.tsx @@ -88,6 +88,19 @@ const LeafletRenderer = forwardRef(f const mapRef = useRef(null); + useEffect(() => { + const container = mapRef.current?.getContainer(); + if (container) { + const ro = new ResizeObserver(() => { + mapRef.current?.invalidateSize(); + }); + ro.observe(container); + return () => { + ro.unobserve(container); + }; + } + }); + useEffect(() => { mapRef.current?.flyToBounds(bounds); }, [`${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`]); From 2b38db61b2711df79e85b256f1460e5db41b6d91 Mon Sep 17 00:00:00 2001 From: AntoineYANG Date: Sat, 8 Jul 2023 17:47:47 +0800 Subject: [PATCH 05/15] feat: choropleth renderer Co-Authored-By: kyusho --- packages/graphic-walker/package.json | 1 + .../leafletRenderer/ChoroplethRenderer.tsx | 270 ++++++++++++++++++ .../leafletRenderer/POIRenderer.tsx | 168 +++++++++++ .../src/components/leafletRenderer/index.tsx | 183 +++--------- packages/graphic-walker/src/config.ts | 2 +- .../src/fields/aestheticFields.tsx | 3 +- .../src/fields/posFields/index.tsx | 2 +- packages/graphic-walker/src/interfaces.ts | 3 + .../graphic-walker/src/locales/en-US.json | 2 +- .../graphic-walker/src/locales/ja-JP.json | 2 +- .../graphic-walker/src/locales/zh-CN.json | 2 +- .../src/store/visualSpecStore.ts | 14 +- .../src/visualSettings/index.tsx | 2 +- yarn.lock | 2 +- 14 files changed, 502 insertions(+), 154 deletions(-) create mode 100644 packages/graphic-walker/src/components/leafletRenderer/ChoroplethRenderer.tsx create mode 100644 packages/graphic-walker/src/components/leafletRenderer/POIRenderer.tsx diff --git a/packages/graphic-walker/package.json b/packages/graphic-walker/package.json index 02205b6a..cc488ca5 100644 --- a/packages/graphic-walker/package.json +++ b/packages/graphic-walker/package.json @@ -64,6 +64,7 @@ "devDependencies": { "@rollup/plugin-typescript": "^8.2.5", "@types/d3-scale": "^4.0.3", + "@types/geojson": "^7946.0.10", "@types/leaflet": "^1.9.3", "@types/react": "^17.x", "@types/react-beautiful-dnd": "^13.1.2", diff --git a/packages/graphic-walker/src/components/leafletRenderer/ChoroplethRenderer.tsx b/packages/graphic-walker/src/components/leafletRenderer/ChoroplethRenderer.tsx new file mode 100644 index 00000000..3159818b --- /dev/null +++ b/packages/graphic-walker/src/components/leafletRenderer/ChoroplethRenderer.tsx @@ -0,0 +1,270 @@ +import React, { Fragment, forwardRef, useEffect, useMemo, useRef } from "react"; +import { CircleMarker, MapContainer, Polygon, Marker, TileLayer, Tooltip } from "react-leaflet"; +import { type Map, divIcon } from "leaflet"; +import type { DeepReadonly, IRow, IViewField, VegaGlobalConfig } from "../../interfaces"; +import type { FeatureCollection, Geometry } from "geojson"; +import { getMeaAggKey } from "../../utils"; +import { useColorScale, useOpacityScale } from "./encodings"; +import { isValidLatLng } from "./POIRenderer"; + + +export interface IChoroplethRendererProps { + data: IRow[]; + allFields: DeepReadonly; + features: FeatureCollection | undefined; + geoKey: string; + defaultAggregated: boolean; + geoId: DeepReadonly; + color: DeepReadonly | undefined; + opacity: DeepReadonly | undefined; + text: DeepReadonly | undefined; + details: readonly DeepReadonly[]; + vegaConfig: VegaGlobalConfig; +} + +export interface IChoroplethRendererRef {} + +const resolveCoords = (featureGeom: Geometry): [lat: number, lng: number][][] => { + switch (featureGeom.type) { + case 'Polygon': { + const coords = featureGeom.coordinates[0]; + return [coords.map<[lat: number, lng: number]>(c => [c[1], c[0]])]; + } + case 'Point': { + const coords = featureGeom.coordinates; + return [[[coords[1], coords[0]]]]; + } + case 'GeometryCollection': { + const coords = featureGeom.geometries.map<[lat: number, lng: number][][]>(resolveCoords); + return coords.flat(); + } + case 'LineString': { + const coords = featureGeom.coordinates; + return [coords.map<[lat: number, lng: number]>(c => [c[1], c[0]])]; + } + case 'MultiLineString': { + const coords = featureGeom.coordinates; + return coords.map<[lat: number, lng: number][]>(c => c.map<[lat: number, lng: number]>(c => [c[1], c[0]])); + } + case 'MultiPoint': { + const coords = featureGeom.coordinates; + return [coords.map<[lat: number, lng: number]>(c => [c[1], c[0]])]; + } + case 'MultiPolygon': { + const coords = featureGeom.coordinates; + return coords.map<[lat: number, lng: number][]>(c => c[0].map<[lat: number, lng: number]>(c => [c[1], c[0]])); + } + default: { + return []; + } + } +}; + +const resolveCenter = (coordinates: [lat: number, lng: number][]): [lng: number, lat: number] => { + let area = 0; + let centroid: [lat: number, lng: number] = [0, 0]; + + for (let i = 0; i < coordinates.length - 1; i++) { + let [x1, y1] = coordinates[i]; + let [x2, y2] = coordinates[i + 1]; + + let tempArea = x1 * y2 - x2 * y1; + area += tempArea; + + centroid[0] += (x1 + x2) * tempArea; + centroid[1] += (y1 + y2) * tempArea; + } + + area /= 2; + + centroid[0] /= 6 * area; + centroid[1] /= 6 * area; + + return centroid; +}; + +const ChoroplethRenderer = forwardRef(function ChoroplethRenderer (props, ref) { + const { data, allFields, features, geoKey, defaultAggregated, geoId, color, opacity, text, details, vegaConfig } = props; + + const geoIndices = useMemo(() => { + if (geoId) { + return data.map(row => row[geoId.fid]); + } + return []; + }, [geoId, data]); + + const geoShapes = useMemo(() => { + if (geoIndices.length && geoKey && features) { + return geoIndices.map(id => { + const feature = id ? features.features.find(f => f.properties?.[geoKey] === id) : undefined; + return feature; + }); + } + return []; + }, [geoIndices, features, geoKey]); + + useEffect(() => { + if (geoShapes.length > 0) { + const notMatched = geoShapes.filter(f => !f); + if (notMatched.length) { + console.warn(`Failed to render ${notMatched.length.toLocaleString()} items of ${data.length.toLocaleString()} rows due to missing geojson feature.`); + } + } + }, [geoShapes]); + + const lngLat = useMemo<[lat: number, lng: number][][][]>(() => { + if (geoShapes.length > 0) { + return geoShapes.map<[lat: number, lng: number][][]>(feature => { + if (feature) { + return resolveCoords(feature.geometry); + } + return []; + }, []); + } + return []; + }, [geoShapes]); + + const [bounds, center] = useMemo<[bounds: [[n: number, w: number], [s: number, e: number]], center: [lng: number, lat: number]]>(() => { + const allLngLat = lngLat.flat(2); + if (allLngLat.length > 0) { + const [bounds, coords] = allLngLat.reduce<[bounds: [[w: number, n: number], [e: number, s: number]], center: [lat: number, lng: number]]>(([bounds, acc], [lat, lng]) => { + if (lng < bounds[0][0]) { + bounds[0][0] = lng; + } + if (lng > bounds[1][0]) { + bounds[1][0] = lng; + } + if (lat < bounds[0][1]) { + bounds[0][1] = lat; + } + if (lat > bounds[1][1]) { + bounds[1][1] = lat; + } + return [bounds, [acc[0] + lng, acc[1] + lat]]; + }, [[[-180, -90], [180, 90]], [0, 0]]); + return [bounds, [coords[0] / lngLat.length, coords[1] / lngLat.length] as [number, number]]; + } + + return [[[-180, -90], [180, 90]], [0, 0]]; + }, [lngLat]); + + const opacityScale = useOpacityScale(data, opacity, defaultAggregated); + const colorScale = useColorScale(data, color, defaultAggregated, vegaConfig); + + const tooltipFields = useMemo(() => { + return details.concat( + [color!, opacity!].filter(Boolean) + ).map(f => ({ + ...f, + key: defaultAggregated && f.analyticType === 'measure' && f.aggName ? getMeaAggKey(f.fid, f.aggName) : f.fid, + })); + }, [defaultAggregated, details, color, opacity]); + + const getFieldName = (fid: string, aggName: string | undefined) => { + const name = allFields.find((f) => f.fid === fid)?.name ?? fid; + return aggName ? `${aggName}(${name})` : name; + }; + + const mapRef = useRef(null); + + useEffect(() => { + const container = mapRef.current?.getContainer(); + if (container) { + const ro = new ResizeObserver(() => { + mapRef.current?.invalidateSize(); + }); + ro.observe(container); + return () => { + ro.unobserve(container); + }; + } + }); + + useEffect(() => { + mapRef.current?.flyToBounds(bounds); + }, [`${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`]); + + return ( + + + {lngLat.length > 0 && data.map((row, i) => { + const coords = lngLat[i]; + const opacity = opacityScale(row); + const color = colorScale(row); + return ( + + {coords.map((coord, j) => { + if (coord.length === 0) { + return null; + } + if (coord.length === 1) { + return ( + + {tooltipFields.length > 0 && ( + +
{data[i][geoId.fid]}
+ {tooltipFields.map(({ fid, aggName, key }, j) => ( +

{getFieldName(fid, aggName)}: {row[key]}

+ ))} +
+ )} +
+ ) + } + const center: [lat: number, lng: number] = text && coord.length >= 3 ? resolveCenter(coord) : [NaN, NaN]; + return ( + + + +
{data[i][geoId.fid]}
+ {tooltipFields.map(({ fid, aggName, key }, j) => ( +

{getFieldName(fid, aggName)}: {row[key]}

+ ))} +
+
+ {text && data[i][text.fid] && isValidLatLng(center[0], center[1]) && ( + ${data[i][text.fid]}
`, + })} + /> + )} + + ); + })} + + ); + })} + + ); +}); + + +export default ChoroplethRenderer; diff --git a/packages/graphic-walker/src/components/leafletRenderer/POIRenderer.tsx b/packages/graphic-walker/src/components/leafletRenderer/POIRenderer.tsx new file mode 100644 index 00000000..d7f8b368 --- /dev/null +++ b/packages/graphic-walker/src/components/leafletRenderer/POIRenderer.tsx @@ -0,0 +1,168 @@ +import React, { forwardRef, useEffect, useMemo, useRef } from "react"; +import { MapContainer, TileLayer, Tooltip, CircleMarker } from "react-leaflet"; +import type { Map } from "leaflet"; +import type { DeepReadonly, IRow, IViewField, VegaGlobalConfig } from "../../interfaces"; +import { getMeaAggKey } from "../../utils"; +import { useColorScale, useOpacityScale, useSizeScale } from "./encodings"; + + +export interface IPOIRendererProps { + data: IRow[]; + allFields: DeepReadonly; + defaultAggregated: boolean; + latitude: DeepReadonly | undefined; + longitude: DeepReadonly | undefined; + color: DeepReadonly | undefined; + opacity: DeepReadonly | undefined; + size: DeepReadonly | undefined; + details: readonly DeepReadonly[]; + vegaConfig: VegaGlobalConfig; +} + +export interface IPOIRendererRef {} + +export const isValidLatLng = (latRaw: unknown, lngRaw: unknown) => { + const lat = Number(latRaw); + const lng = Number(lngRaw); + return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; +}; + +const formatCoerceLatLng = (latRaw: unknown, lngRaw: unknown) => { + return `${ + typeof latRaw === 'number' ? latRaw : JSON.stringify(latRaw) + }, ${ + typeof lngRaw === 'number' ? lngRaw : JSON.stringify(lngRaw) + }`; +}; + +const debugMaxLen = 20; + +const POIRenderer = forwardRef(function POIRenderer (props, ref) { + const { data, allFields, latitude, longitude, color, opacity, size, details, defaultAggregated, vegaConfig } = props; + + const lngLat = useMemo<[lat: number, lng: number][]>(() => { + if (longitude && latitude) { + return data.map<[lat: number, lng: number]>(row => [Number(row[latitude.fid]), Number(row[longitude.fid])]).filter(v => isValidLatLng(v[0], v[1])); + } + return []; + }, [longitude, latitude, data]); + + const [bounds, center] = useMemo<[bounds: [[n: number, w: number], [s: number, e: number]], center: [lng: number, lat: number]]>(() => { + if (lngLat.length > 0) { + const [bounds, coords] = lngLat.reduce<[bounds: [[w: number, n: number], [e: number, s: number]], center: [lat: number, lng: number]]>(([bounds, acc], [lat, lng]) => { + if (lng < bounds[0][0]) { + bounds[0][0] = lng; + } + if (lng > bounds[1][0]) { + bounds[1][0] = lng; + } + if (lat < bounds[0][1]) { + bounds[0][1] = lat; + } + if (lat > bounds[1][1]) { + bounds[1][1] = lat; + } + return [bounds, [acc[0] + lng, acc[1] + lat]]; + }, [[[-180, -90], [180, 90]], [0, 0]]); + return [bounds, [coords[0] / lngLat.length, coords[1] / lngLat.length] as [number, number]]; + } + + return [[[-180, -90], [180, 90]], [0, 0]]; + }, [lngLat]); + + const failedLatLngListRef = useRef<[index: number, lng: unknown, lat: unknown][]>([]); + failedLatLngListRef.current = []; + + useEffect(() => { + if (failedLatLngListRef.current.length > 0) { + console.warn(`Failed to render ${failedLatLngListRef.current.length.toLocaleString()} markers of ${data.length.toLocaleString()} rows due to invalid lat/lng.\n--------\n${ + `${failedLatLngListRef.current.slice(0, debugMaxLen).map(([idx, lng, lat]) => + `[${idx + 1}] ${formatCoerceLatLng(lat, lng)}` + ).join('\n')}` + + (failedLatLngListRef.current.length > debugMaxLen ? `\n\t... and ${(failedLatLngListRef.current.length - debugMaxLen).toLocaleString()} more` : '') + }\n`); + } + }); + + const mapRef = useRef(null); + + useEffect(() => { + const container = mapRef.current?.getContainer(); + if (container) { + const ro = new ResizeObserver(() => { + mapRef.current?.invalidateSize(); + }); + ro.observe(container); + return () => { + ro.unobserve(container); + }; + } + }); + + useEffect(() => { + mapRef.current?.flyToBounds(bounds); + }, [`${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`]); + + const sizeScale = useSizeScale(data, size, defaultAggregated); + const opacityScale = useOpacityScale(data, opacity, defaultAggregated); + const colorScale = useColorScale(data, color, defaultAggregated, vegaConfig); + + const tooltipFields = useMemo(() => { + return details.concat( + [size!, color!, opacity!].filter(Boolean) + ).map(f => ({ + ...f, + key: defaultAggregated && f.analyticType === 'measure' && f.aggName ? getMeaAggKey(f.fid, f.aggName) : f.fid, + })); + }, [defaultAggregated, details, size, color, opacity]); + + const getFieldName = (fid: string, aggName: string | undefined) => { + const name = allFields.find((f) => f.fid === fid)?.name ?? fid; + return aggName ? `${aggName}(${name})` : name; + }; + + return ( + + + {Boolean(latitude && longitude) && data.map((row, i) => { + const lat = row[latitude!.fid]; + const lng = row[longitude!.fid]; + if (!isValidLatLng(lat, lng)) { + failedLatLngListRef.current.push([i, lat, lng]); + return null; + } + const radius = sizeScale(row); + const opacity = opacityScale(row); + const color = colorScale(row); + return ( + + {tooltipFields.length > 0 && ( + + {tooltipFields.map(({ fid, aggName, key }, j) => ( +

{getFieldName(fid, aggName)}: {row[key]}

+ ))} +
+ )} +
+ ); + })} +
+ ); +}); + + +export default POIRenderer; diff --git a/packages/graphic-walker/src/components/leafletRenderer/index.tsx b/packages/graphic-walker/src/components/leafletRenderer/index.tsx index 25f53fba..66b7c411 100644 --- a/packages/graphic-walker/src/components/leafletRenderer/index.tsx +++ b/packages/graphic-walker/src/components/leafletRenderer/index.tsx @@ -1,9 +1,7 @@ -import React, { forwardRef, useEffect, useMemo, useRef } from "react"; -import { MapContainer, TileLayer, Tooltip, CircleMarker } from "react-leaflet"; -import type { Map } from "leaflet"; +import React, { forwardRef, useMemo } from "react"; import type { DeepReadonly, DraggableFieldState, IRow, IVisualConfig, VegaGlobalConfig } from "../../interfaces"; -import { getMeaAggKey } from "../../utils"; -import { useColorScale, useOpacityScale, useSizeScale } from "./encodings"; +import POIRenderer from "./POIRenderer"; +import ChoroplethRenderer from "./ChoroplethRenderer"; export interface ILeafletRendererProps { @@ -15,155 +13,50 @@ export interface ILeafletRendererProps { export interface ILeafletRendererRef {} -const isValidLatLng = (latRaw: unknown, lngRaw: unknown) => { - const lat = Number(latRaw); - const lng = Number(lngRaw); - return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; -}; - -const formatCoerceLatLng = (latRaw: unknown, lngRaw: unknown) => { - return `${ - typeof latRaw === 'number' ? latRaw : JSON.stringify(latRaw) - }, ${ - typeof lngRaw === 'number' ? lngRaw : JSON.stringify(lngRaw) - }`; -}; - -const debugMaxLen = 20; - const LeafletRenderer = forwardRef(function LeafletRenderer (props, ref) { const { draggableFieldState, data, visualConfig, vegaConfig = {} } = props; - const { latitude: [lat], longitude: [lng], details, dimensions, measures, size, color, shape, opacity, text } = draggableFieldState; - const { defaultAggregated } = visualConfig; + const { latitude: [lat], longitude: [lng], geoId: [geoId], dimensions, measures, size: [size], color: [color], opacity: [opacity], text: [text], details } = draggableFieldState; + const { defaultAggregated, geoms: [markType], geojson, geoKey = '' } = visualConfig; const allFields = useMemo(() => [...dimensions, ...measures], [dimensions, measures]); const latField = useMemo(() => allFields.find((f) => f.geoRole === 'latitude'), [allFields]); const lngField = useMemo(() => allFields.find((f) => f.geoRole === 'longitude'), [allFields]); const latitude = useMemo(() => lat ?? latField, [lat, latField]); const longitude = useMemo(() => lng ?? lngField, [lng, lngField]); - - // TODO: web worker - const lngLat = useMemo<[lat: number, lng: number][]>(() => { - if (longitude && latitude) { - return data.map<[lat: number, lng: number]>(row => [Number(row[latitude.fid]), Number(row[longitude.fid])]).filter(v => isValidLatLng(v[0], v[1])); - } - return []; - }, [longitude, latitude, data]); - - const [bounds, center] = useMemo<[bounds: [[n: number, w: number], [s: number, e: number]], center: [lng: number, lat: number]]>(() => { - if (lngLat.length > 0) { - const [bounds, coords] = lngLat.reduce<[bounds: [[w: number, n: number], [e: number, s: number]], center: [lat: number, lng: number]]>(([bounds, acc], [lat, lng]) => { - if (lng < bounds[0][0]) { - bounds[0][0] = lng; - } - if (lng > bounds[1][0]) { - bounds[1][0] = lng; - } - if (lat < bounds[0][1]) { - bounds[0][1] = lat; - } - if (lat > bounds[1][1]) { - bounds[1][1] = lat; - } - return [bounds, [acc[0] + lng, acc[1] + lat]]; - }, [[[-180, -90], [180, 90]], [0, 0]]); - return [bounds, [coords[0] / lngLat.length, coords[1] / lngLat.length] as [number, number]]; - } - - return [[[-180, -90], [180, 90]], [0, 0]]; - }, [lngLat]); - - const failedLatLngListRef = useRef<[index: number, lng: unknown, lat: unknown][]>([]); - failedLatLngListRef.current = []; - - useEffect(() => { - if (failedLatLngListRef.current.length > 0) { - console.warn(`Failed to render ${failedLatLngListRef.current.length.toLocaleString()} markers of ${data.length.toLocaleString()} rows due to invalid lat/lng.\n--------\n${ - `${failedLatLngListRef.current.slice(0, debugMaxLen).map(([idx, lng, lat]) => - `[${idx + 1}] ${formatCoerceLatLng(lat, lng)}` - ).join('\n')}` - + (failedLatLngListRef.current.length > debugMaxLen ? `\n\t... and ${(failedLatLngListRef.current.length - debugMaxLen).toLocaleString()} more` : '') - }\n`); - } - }); - - const mapRef = useRef(null); - useEffect(() => { - const container = mapRef.current?.getContainer(); - if (container) { - const ro = new ResizeObserver(() => { - mapRef.current?.invalidateSize(); - }); - ro.observe(container); - return () => { - ro.unobserve(container); - }; - } - }); - - useEffect(() => { - mapRef.current?.flyToBounds(bounds); - }, [`${bounds[0][0]},${bounds[0][1]},${bounds[1][0]},${bounds[1][1]}`]); - - const sizeScale = useSizeScale(data, size[0], defaultAggregated); - const opacityScale = useOpacityScale(data, opacity[0], defaultAggregated); - const colorScale = useColorScale(data, color[0], defaultAggregated, vegaConfig); - - const tooltipFields = useMemo(() => { - return details.concat( - [size, color, shape, opacity, text].map((enc) => enc[0]).filter(Boolean) - ).map(f => ({ - ...f, - key: defaultAggregated && f.analyticType === 'measure' && f.aggName ? getMeaAggKey(f.fid, f.aggName) : f.fid, - })); - }, [defaultAggregated, details, size, color, shape, opacity, text]); - - const getFieldName = (fid: string, aggName: string | undefined) => { - const name = allFields.find((f) => f.fid === fid)?.name ?? fid; - return aggName ? `${aggName}(${name})` : name; - }; - - return ( - - + ); + } else if (markType === 'choropleth') { + return ( + - {Boolean(latitude && longitude) && data.map((row, i) => { - const lat = row[latitude.fid]; - const lng = row[longitude.fid]; - if (!isValidLatLng(lat, lng)) { - failedLatLngListRef.current.push([i, lat, lng]); - return null; - } - const radius = sizeScale(row); - const opacity = opacityScale(row); - const color = colorScale(row); - return ( - - {tooltipFields.length > 0 && ( - - {tooltipFields.map(({ fid, aggName, key }, j) => ( -

{getFieldName(fid, aggName)}: {row[key]}

- ))} -
- )} -
- ); - })} -
- ); + ); + } + + return null; }); diff --git a/packages/graphic-walker/src/config.ts b/packages/graphic-walker/src/config.ts index 6e48d3c7..3cbfcf20 100644 --- a/packages/graphic-walker/src/config.ts +++ b/packages/graphic-walker/src/config.ts @@ -18,7 +18,7 @@ export const GEMO_TYPES: Record> = { ], geographic: [ 'poi', - 'geoshape', + 'choropleth', ], } as const; diff --git a/packages/graphic-walker/src/fields/aestheticFields.tsx b/packages/graphic-walker/src/fields/aestheticFields.tsx index d61b8352..99dc41b5 100644 --- a/packages/graphic-walker/src/fields/aestheticFields.tsx +++ b/packages/graphic-walker/src/fields/aestheticFields.tsx @@ -27,8 +27,9 @@ const AestheticFields: React.FC = props => { case 'table': return [] case 'poi': - case 'geoshape': return aestheticFields.filter(f => f.id === 'color' || f.id === 'opacity' || f.id === 'size' || f.id === 'details'); + case 'choropleth': + return aestheticFields.filter(f => f.id === 'color' || f.id === 'opacity' || f.id === 'text' || f.id === 'details'); default: return aestheticFields.filter(f => f.id !== 'text'); } diff --git a/packages/graphic-walker/src/fields/posFields/index.tsx b/packages/graphic-walker/src/fields/posFields/index.tsx index 9d4df4cf..821c5be2 100644 --- a/packages/graphic-walker/src/fields/posFields/index.tsx +++ b/packages/graphic-walker/src/fields/posFields/index.tsx @@ -13,7 +13,7 @@ const PosFields: React.FC = props => { const channels = useMemo(() => { if (coordSystem === 'geographic') { - if (geoms[0] === 'geoshape') { + if (geoms[0] === 'choropleth') { return DRAGGABLE_STATE_KEYS.filter(f => f.id === 'geoId'); } return DRAGGABLE_STATE_KEYS.filter(f => f.id === 'longitude' || f.id === 'latitude'); diff --git a/packages/graphic-walker/src/interfaces.ts b/packages/graphic-walker/src/interfaces.ts index 04ac3296..482c6060 100644 --- a/packages/graphic-walker/src/interfaces.ts +++ b/packages/graphic-walker/src/interfaces.ts @@ -1,5 +1,6 @@ import {Config as VgConfig} from 'vega'; import {Config as VlConfig} from 'vega-lite'; +import type { FeatureCollection } from 'geojson'; export type DeepReadonly> = { readonly [K in keyof T]: T[K] extends Record ? DeepReadonly : T[K]; @@ -216,6 +217,8 @@ export interface IVisualConfig { width: number; height: number; }; + geojson?: FeatureCollection; + geoKey?: string; } export interface IVisSpec { diff --git a/packages/graphic-walker/src/locales/en-US.json b/packages/graphic-walker/src/locales/en-US.json index b334e0fb..c8fb9d8f 100644 --- a/packages/graphic-walker/src/locales/en-US.json +++ b/packages/graphic-walker/src/locales/en-US.json @@ -33,7 +33,7 @@ "table": "Table", "text": "Text", "poi": "POI", - "geoshape": "Geo Shape" + "choropleth": "Choropleth" }, "coord_system": { "__enum__": "Coordinate System", diff --git a/packages/graphic-walker/src/locales/ja-JP.json b/packages/graphic-walker/src/locales/ja-JP.json index 137a9113..c160cd00 100644 --- a/packages/graphic-walker/src/locales/ja-JP.json +++ b/packages/graphic-walker/src/locales/ja-JP.json @@ -33,7 +33,7 @@ "table": "表", "text": "テキスト", "poi": "POI", - "geoshape": "形状" + "choropleth": "コロプレス" }, "coord_system": { "__enum__": "座標系", diff --git a/packages/graphic-walker/src/locales/zh-CN.json b/packages/graphic-walker/src/locales/zh-CN.json index 8047fb6b..815a3642 100644 --- a/packages/graphic-walker/src/locales/zh-CN.json +++ b/packages/graphic-walker/src/locales/zh-CN.json @@ -33,7 +33,7 @@ "table": "表格", "text": "文本", "poi": "兴趣点", - "geoshape": "地理形状" + "choropleth": "区域图" }, "coord_system": { "__enum__": "坐标系统", diff --git a/packages/graphic-walker/src/store/visualSpecStore.ts b/packages/graphic-walker/src/store/visualSpecStore.ts index ec9105ad..c97b65c2 100644 --- a/packages/graphic-walker/src/store/visualSpecStore.ts +++ b/packages/graphic-walker/src/store/visualSpecStore.ts @@ -1,5 +1,6 @@ import { IReactionDisposer, makeAutoObservable, observable, reaction, toJS } from "mobx"; import produce from "immer"; +import type { FeatureCollection } from "geojson"; import { DataSet, DraggableFieldState, IFilterRule, IViewField, IVisSpec, IVisualConfig, Specification } from "../interfaces"; import { CHANNEL_LIMIT, GEMO_TYPES, MetaFieldKeys } from "../config"; import { VisSpecWithHistory } from "../models/visSpecHistory"; @@ -84,7 +85,8 @@ export function initVisualConfig(): IVisualConfig { numberFormat: undefined, timeFormat: undefined, normalizedNumberFormat: undefined - } + }, + geoKey: 'name', }; } @@ -728,4 +730,14 @@ export class VizSpecStore { const content = parseGWContent(raw); this.importStoInfo(content); } + public setGeoJSON(geoJSON: FeatureCollection) { + this.useMutable(({ config }) => { + config.geojson = geoJSON; + }); + } + public setGeoKey(key: string) { + this.useMutable(({ config }) => { + config.geoKey = key; + }); + } } diff --git a/packages/graphic-walker/src/visualSettings/index.tsx b/packages/graphic-walker/src/visualSettings/index.tsx index f11b7286..73c0eac5 100644 --- a/packages/graphic-walker/src/visualSettings/index.tsx +++ b/packages/graphic-walker/src/visualSettings/index.tsx @@ -166,7 +166,7 @@ const VisualSettings: React.FC = ({ rendererHandler, darkModePr boxplot: (props: SVGProps) => , table: (props: SVGProps) => , poi: MapPinIcon, - geoshape: RectangleGroupIcon, + choropleth: RectangleGroupIcon, }[g], })), value: markType, diff --git a/yarn.lock b/yarn.lock index e854c449..44ea3f20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -737,7 +737,7 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83" integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw== -"@types/geojson@*": +"@types/geojson@*", "@types/geojson@^7946.0.10": version "7946.0.10" resolved "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249" integrity sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA== From 6d23284e46be37e5c254a7eb3c4c6d89aff04517 Mon Sep 17 00:00:00 2001 From: AntoineYANG Date: Sat, 8 Jul 2023 18:21:37 +0800 Subject: [PATCH 06/15] feat: geojson config Co-Authored-By: kyusho --- packages/graphic-walker/src/App.tsx | 2 + .../leafletRenderer/geoConfigPanel.tsx | 122 ++++++++++++++++++ .../graphic-walker/src/locales/en-US.json | 9 ++ .../graphic-walker/src/locales/ja-JP.json | 9 ++ .../graphic-walker/src/locales/zh-CN.json | 9 ++ .../graphic-walker/src/store/commonStore.ts | 4 + .../src/visualSettings/index.tsx | 11 +- 7 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 packages/graphic-walker/src/components/leafletRenderer/geoConfigPanel.tsx diff --git a/packages/graphic-walker/src/App.tsx b/packages/graphic-walker/src/App.tsx index 3e9f8d6a..15afb45f 100644 --- a/packages/graphic-walker/src/App.tsx +++ b/packages/graphic-walker/src/App.tsx @@ -19,6 +19,7 @@ import DatasetConfig from './dataSource/datasetConfig'; import { useCurrentMediaTheme } from './utils/media'; import CodeExport from './components/codeExport'; import VisualConfig from './components/visualConfig'; +import GeoConfigPanel from './components/leafletRenderer/geoConfigPanel'; import type { ToolbarItemProps } from './components/toolbar'; export interface IGWProps { @@ -133,6 +134,7 @@ const App = observer(function App(props) { +
diff --git a/packages/graphic-walker/src/components/leafletRenderer/geoConfigPanel.tsx b/packages/graphic-walker/src/components/leafletRenderer/geoConfigPanel.tsx new file mode 100644 index 00000000..e72150fc --- /dev/null +++ b/packages/graphic-walker/src/components/leafletRenderer/geoConfigPanel.tsx @@ -0,0 +1,122 @@ +import { observer } from 'mobx-react-lite'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { runInAction } from 'mobx'; +import { useGlobalStore } from '../../store'; +import Modal from '../modal'; +import PrimaryButton from '../button/primary'; +import DefaultButton from '../button/default'; + +const GeoConfigPanel: React.FC = (props) => { + const { commonStore, vizStore } = useGlobalStore(); + const { showGeoJSONConfigPanel } = commonStore; + const { visualConfig } = vizStore; + const { geoKey, geojson } = visualConfig; + const { t: tGlobal } = useTranslation('translation'); + const { t } = useTranslation('translation', { keyPrefix: 'main.tabpanel.settings' }); + + const [featureId, setFeatureId] = useState(''); + const [url, setUrl] = useState(''); + const [geoJSON, setGeoJSON] = useState(''); + + useEffect(() => { + setFeatureId(geoKey || ''); + }, [geoKey]); + + useEffect(() => { + setGeoJSON(geojson ? JSON.stringify(geojson, null, 2) : ''); + }, [geojson]); + + return ( + { + commonStore.setShowGeoJSONConfigPanel(false); + }} + > +
+

{t('geojson')}

+
+
+ +
+ { + setFeatureId(e.target.value); + }} + /> +
+
+
+ +
+
+ + { + setUrl(e.target.value); + }} + /> + { + if (url) { + fetch(url) + .then((res) => res.json()) + .then((json) => { + setGeoJSON(JSON.stringify(json, null, 2)); + }); + } + }} + /> +
+