Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: leaflet geojson support #94

Merged
merged 20 commits into from
Aug 10, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e8b1741
feat: leaflet geojson support
k6sdevbob Jul 4, 2023
0aa4595
feat(map): scale support
k6sdevbob Jul 4, 2023
c2efe91
feat: coord system
k6sdevbob Jul 4, 2023
2c5b6e4
fix: missing marks on container resize
k6sdevbob Jul 4, 2023
60dfc01
Merge branch 'main' of github.com:Kanaries/graphic-walker into feat-l…
k6sdevbob Jul 8, 2023
2b38db6
feat: choropleth renderer
k6sdevbob Jul 8, 2023
6d23284
feat: geojson config
k6sdevbob Jul 8, 2023
97b75b9
feat(geojson): support gw props
k6sdevbob Jul 8, 2023
bcf7546
feat: topojson support
k6sdevbob Jul 9, 2023
3d37a09
feat: specify topojson object key for generating geojson
k6sdevbob Jul 9, 2023
6332edd
Merge branch 'main' of github.com:Kanaries/graphic-walker into feat-l…
k6sdevbob Jul 11, 2023
a60636b
fix(geo): displayed aggr prefix of dimsensions by mistake
k6sdevbob Jul 12, 2023
54961df
feat(geo): sync vega format settings
k6sdevbob Jul 12, 2023
b779a85
Merge branch 'main' of github.com:Kanaries/graphic-walker into feat-l…
k6sdevbob Jul 17, 2023
f531296
feat(leaflet): auto adjust default size
k6sdevbob Jul 17, 2023
d6a8c20
feat(leaflet): new config scaleIncludeUnmatchedChoropleth
k6sdevbob Jul 17, 2023
57bb5d8
Merge branch 'main' of github.com:Kanaries/graphic-walker into feat-l…
k6sdevbob Jul 28, 2023
4d34532
Merge branch 'main' of github.com:Kanaries/graphic-walker into feat-l…
k6sdevbob Aug 7, 2023
071ecea
fix: provide value for missing field in encoding
islxyqwe Aug 8, 2023
2d6ff99
fix: hide export when is map
islxyqwe Aug 10, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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