Skip to content

Commit

Permalink
feat: leaflet geojson support (#94)
Browse files Browse the repository at this point in the history
* feat: leaflet geojson support

* feat(map): scale support

* feat: coord system

* 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.

* feat: choropleth renderer

* feat: geojson config

* feat(geojson): support gw props

* feat: topojson support

* feat: specify topojson object key for generating geojson

* fix(geo): displayed aggr prefix of dimsensions by mistake

* feat(geo): sync vega format settings

* feat(leaflet): auto adjust default size

* feat(leaflet): new config scaleIncludeUnmatchedChoropleth

* fix: provide value for missing field in encoding

* fix: hide export when is map

---------

Co-authored-by: islxyqwe <[email protected]>
  • Loading branch information
k6sdevbob and islxyqwe authored Aug 10, 2023
1 parent 2b7bcda commit 3fd9ee3
Show file tree
Hide file tree
Showing 27 changed files with 1,517 additions and 92 deletions.
12 changes: 12 additions & 0 deletions packages/graphic-walker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,30 +40,42 @@
"@kanaries/web-data-loader": "^0.1.7",
"@tailwindcss/forms": "^0.5.4",
"autoprefixer": "^10.3.5",
"d3-format": "^3.1.0",
"d3-scale": "^4.0.2",
"d3-time-format": "^4.1.0",
"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",
"postcss": "^8.3.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",
"topojson-client": "^3.1.0",
"uuid": "^8.3.2",
"vega": "^5.22.1",
"vega-embed": "^6.21.0",
"vega-lite": "^5.6.0"
},
"devDependencies": {
"@rollup/plugin-typescript": "^8.2.5",
"@types/d3-format": "^3.0.1",
"@types/d3-scale": "^4.0.3",
"@types/d3-time-format": "^4.0.0",
"@types/geojson": "^7946.0.10",
"@types/leaflet": "^1.9.3",
"@types/react": "^17.x",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.x",
"@types/styled-components": "^5.1.26",
"@types/topojson-client": "^3.1.1",
"@types/uuid": "^8.3.1",
"@vercel/analytics": "^0.1.8",
"@vitejs/plugin-react": "^3.1.0",
Expand Down
14 changes: 13 additions & 1 deletion packages/graphic-walker/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useMemo, useState } from 'react';
import { observer } from 'mobx-react-lite';
import { useTranslation } from 'react-i18next';
import { IComputationFunction, IDarkMode, IMutField, IRow, ISegmentKey, IThemeKey, Specification } from './interfaces';
import { IGeographicData, IComputationFunction, IDarkMode, IMutField, IRow, ISegmentKey, IThemeKey, Specification } from './interfaces';
import type { IReactVegaHandler } from './vis/react-vega';
import VisualSettings from './visualSettings';
import PosFields from './fields/posFields';
Expand All @@ -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';
import AskViz from './components/askViz';
import { getComputation } from './computation/clientComputation';
Expand All @@ -44,6 +45,9 @@ export interface IGWProps {
extra?: ToolbarItemProps[];
exclude?: string[];
};
geographicData?: IGeographicData & {
key: string;
};
enhanceAPI?: {
header?: Record<string, string>;
features?: {
Expand All @@ -65,6 +69,7 @@ const App = observer<IGWProps>(function App(props) {
dark = 'media',
computation,
toolbar,
geographicData,
enhanceAPI,
} = props;
const { commonStore, vizStore } = useGlobalStore();
Expand Down Expand Up @@ -134,6 +139,12 @@ const App = observer<IGWProps>(function App(props) {
}
}, [spec, safeDataset]);

useEffect(() => {
if (geographicData) {
vizStore.setGeographicData(geographicData, geographicData.key);
}
}, [geographicData]);

useEffect(() => {
if (computation) {
vizStore.setComputationFunction(computation);
Expand Down Expand Up @@ -172,6 +183,7 @@ const App = observer<IGWProps>(function App(props) {
<VisualSettings rendererHandler={rendererRef} darkModePreference={dark} exclude={toolbar?.exclude} extra={toolbar?.extra} />
<CodeExport />
<VisualConfig />
<GeoConfigPanel />
<div className="md:grid md:grid-cols-12 xl:grid-cols-6">
<div className="md:col-span-3 xl:col-span-1">
<DatasetFields />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import React, { Fragment, forwardRef, useEffect, useImperativeHandle, 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";
import { TooltipContent } from "./tooltip";


export interface IChoroplethRendererProps {
data: IRow[];
allFields: DeepReadonly<IViewField[]>;
features: FeatureCollection | undefined;
geoKey: string;
defaultAggregated: boolean;
geoId: DeepReadonly<IViewField>;
color: DeepReadonly<IViewField> | undefined;
opacity: DeepReadonly<IViewField> | undefined;
text: DeepReadonly<IViewField> | undefined;
details: readonly DeepReadonly<IViewField>[];
vegaConfig: VegaGlobalConfig;
scaleIncludeUnmatchedChoropleth: boolean;
}

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<IChoroplethRendererRef, IChoroplethRendererProps>(function ChoroplethRenderer (props, ref) {
const { data, allFields, features, geoKey, defaultAggregated, geoId, color, opacity, text, details, vegaConfig, scaleIncludeUnmatchedChoropleth } = props;

useImperativeHandle(ref, () => ({}));

const geoIndices = useMemo(() => {
if (geoId) {
return data.map(row => row[geoId.fid]);
}
return [];
}, [geoId, data]);

const [indices, geoShapes] = useMemo<[indices: number[], geoShapes: (FeatureCollection['features'][number] | undefined)[]]>(() => {
if (geoIndices.length && geoKey && features) {
const indices: number[] = [];
const shapes = geoIndices.map((id, i) => {
const feature = id ? features.features.find(f => f.properties?.[geoKey] === id) : undefined;
if (feature) {
indices.push(i);
}
return feature;
});
return [indices, shapes];
}
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 distribution = useMemo(() => {
if (scaleIncludeUnmatchedChoropleth) {
return data;
}
return indices.map(i => data[i]);
}, [data, indices, scaleIncludeUnmatchedChoropleth]);

const opacityScale = useOpacityScale(distribution, opacity, defaultAggregated);
const colorScale = useColorScale(distribution, 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 mapRef = useRef<Map>(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 (
<MapContainer center={center} ref={mapRef} zoom={5} bounds={bounds} style={{ width: '100%', height: '100%', zIndex: 1 }}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{lngLat.length > 0 && data.map((row, i) => {
const coords = lngLat[i];
const opacity = opacityScale(row);
const color = colorScale(row);
return (
<Fragment key={`${i}-${opacity}-${color}`}>
{coords.map((coord, j) => {
if (coord.length === 0) {
return null;
}
if (coord.length === 1) {
return (
<CircleMarker
key={j}
center={coord[0]}
radius={3}
opacity={0.8}
fillOpacity={opacity}
fillColor={color}
color="#0004"
weight={1}
stroke
fill
>
{tooltipFields.length > 0 && (
<Tooltip>
<header>{data[i][geoId.fid]}</header>
{tooltipFields.map((f, j) => (
<TooltipContent
key={j}
allFields={allFields}
vegaConfig={vegaConfig}
field={f}
value={row[f.key]}
/>
))}
</Tooltip>
)}
</CircleMarker>
)
}
const center: [lat: number, lng: number] = text && coord.length >= 3 ? resolveCenter(coord) : [NaN, NaN];
return (
<Fragment key={j}>
<Polygon
positions={coord}
pathOptions={{
fillOpacity: opacity * 0.8,
fillColor: color,
color: "#0004",
weight: 1,
stroke: true,
fill: true,
}}
>
<Tooltip>
<header>{data[i][geoId.fid]}</header>
{tooltipFields.map((f, j) => (
<TooltipContent
key={j}
allFields={allFields}
vegaConfig={vegaConfig}
field={f}
value={row[f.key]}
/>
))}
</Tooltip>
</Polygon>
{text && data[i][text.fid] && isValidLatLng(center[0], center[1]) && (
<Marker
position={center}
interactive={false}
icon={divIcon({
className: '!bg-transparent !border-none',
html: `<div style="font-size: 11px; transform: translate(-50%, -50%); opacity: 0.8;">${data[i][text.fid]}</div>`,
})}
/>
)}
</Fragment>
);
})}
</Fragment>
);
})}
</MapContainer>
);
});


export default ChoroplethRenderer;
Loading

1 comment on commit 3fd9ee3

@vercel
Copy link

@vercel vercel bot commented on 3fd9ee3 Aug 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.