diff --git a/app/.env.development b/app/.env.development index 639db9e7a..a29f1f3be 100644 --- a/app/.env.development +++ b/app/.env.development @@ -1,4 +1,5 @@ DATABASE_URL=postgres://postgres:password@localhost:5432/visualization_tool SPARQL_ENDPOINT=https://lindas.admin.ch/query +SPARQL_GEO_ENDPOINT=https://geo.ld.admin.ch/query SPARQL_EDITOR=https://lindas.admin.ch/sparql/ GRAPHQL_ENDPOINT=/api/graphql diff --git a/app/charts/chart-config-ui-options.ts b/app/charts/chart-config-ui-options.ts index 14e7ee8cf..8bc4627cc 100644 --- a/app/charts/chart-config-ui-options.ts +++ b/app/charts/chart-config-ui-options.ts @@ -11,10 +11,14 @@ export type DimensionType = | "TemporalDimension" | "NominalDimension" | "OrdinalDimension" + | "GeoCoordinatesDimension" + | "GeoShapesDimension" | "Measure" | "Attribute"; -export type EncodingField = "x" | "y" | "segment"; +export type BaseEncodingField = "x" | "y" | "segment" | "settings"; +export type GeoEncodingField = "areaLayer" | "symbolLayer"; +export type EncodingField = BaseEncodingField | GeoEncodingField; export type EncodingOption = | "chartSubType" | "sorting" @@ -228,7 +232,26 @@ export const chartConfigOptionsUISpec: ChartSpecs = { }, map: { chartType: "map", - encodings: [], + encodings: [ + { + field: "settings", + optional: true, + values: ["Attribute"], // FIXME: currently not used anywhere + filters: false, + }, + { + field: "areaLayer", + optional: false, + values: ["Measure"], + filters: false, + }, + { + field: "symbolLayer", + optional: true, + values: ["Measure"], + filters: false, + }, + ], interactiveFilters: [], }, }; diff --git a/app/charts/index.ts b/app/charts/index.ts index a704a7482..288170e04 100644 --- a/app/charts/index.ts +++ b/app/charts/index.ts @@ -1,3 +1,4 @@ +import { groupBy } from "lodash"; import { ChartConfig, ChartType, @@ -5,7 +6,11 @@ import { TableColumn, } from "../configurator"; import { mapColorsToComponentValuesIris } from "../configurator/components/ui-helpers"; -import { getCategoricalDimensions, getTimeDimensions } from "../domain/data"; +import { + getCategoricalDimensions, + getGeoDimensions, + getTimeDimensions, +} from "../domain/data"; import { DimensionMetaDataFragment } from "../graphql/query-hooks"; import { DataCubeMetadata } from "../graphql/types"; import { unreachableError } from "../lib/unreachable"; @@ -18,6 +23,7 @@ export const enabledChartTypes: ChartType[] = [ "scatterplot", "pie", "table", + "map", ]; /** @@ -246,6 +252,11 @@ export const getInitialConfig = ({ ), }; case "map": + const { + GeoShapesDimension: geoShapes = [], + GeoCoordinatesDimension: geoCoordinates = [], + } = groupBy(getGeoDimensions(dimensions), (d) => d.__typename); + return { chartType, filters: {}, @@ -262,32 +273,27 @@ export const getInitialConfig = ({ }, }, fields: { - baseLayer: { - componentIri: dimensions[0].iri, - relief: true, - lakes: true, - }, areaLayer: { - componentIri: measures[0].iri, - show: false, - label: { componentIri: dimensions[0].iri }, + show: geoShapes.length > 0, + componentIri: geoShapes[0]?.iri || "", + measureIri: measures[0].iri, + hierarchyLevel: 1, palette: "oranges", nbClass: 5, paletteType: "continuous", }, symbolLayer: { - show: false, - componentIri: measures[0].iri, - }, - // FIXME: unused fields - x: { componentIri: dimensions[0].iri }, - y: { - componentIri: measures[0].iri, - }, - segment: { - componentIri: dimensions[0].iri, + show: geoShapes.length === 0, + componentIri: geoCoordinates[0]?.iri || geoShapes[0]?.iri || "", + measureIri: measures[0].iri, + hierarchyLevel: 1, + color: "#1f77b4", }, }, + settings: { + showRelief: true, + showLakes: true, + }, }; // This code *should* be unreachable! If it's not, it means we haven't checked all cases (and we should get a TS error). @@ -297,21 +303,18 @@ export const getInitialConfig = ({ }; export const getPossibleChartType = ({ - chartTypes = enabledChartTypes, meta, }: { - chartTypes?: ChartType[]; meta: DataCubeMetadata; }): ChartType[] => { const { measures, dimensions } = meta; const hasZeroQ = measures.length === 0; const hasMultipleQ = measures.length > 1; - const hasTime = dimensions.some( - (dim) => dim.__typename === "TemporalDimension" - ); + const hasGeo = getGeoDimensions(dimensions).length > 0; + const hasTime = getTimeDimensions(dimensions).length > 0; - // const geoBased: ChartType[] = ["map"]; + const geoBased: ChartType[] = ["map"]; const catBased: ChartType[] = ["bar", "column", "pie", "table"]; const multipleQ: ChartType[] = ["scatterplot"]; const timeBased: ChartType[] = ["line", "area"]; @@ -319,18 +322,22 @@ export const getPossibleChartType = ({ let possibles: ChartType[] = []; if (hasZeroQ) { possibles = ["table"]; - } else if (hasMultipleQ && hasTime) { - possibles = [...multipleQ, ...timeBased, ...catBased]; - } else if (hasMultipleQ && !hasTime) { - possibles = [...multipleQ, ...catBased]; - } else if (!hasMultipleQ && hasTime) { - possibles = [...catBased, ...timeBased]; - } else if (!hasMultipleQ && !hasTime) { - possibles = [...catBased]; } else { - // Tables should always be possible - possibles = ["table"]; + possibles.push(...catBased); + + if (hasMultipleQ) { + possibles.push(...multipleQ); + } + + if (hasTime) { + possibles.push(...timeBased); + } + + if (hasGeo) { + possibles.push(...geoBased); + } } + return enabledChartTypes.filter((type) => possibles.includes(type)); }; diff --git a/app/charts/map/chart-map-prototype.tsx b/app/charts/map/chart-map-prototype.tsx deleted file mode 100644 index daa1b6900..000000000 --- a/app/charts/map/chart-map-prototype.tsx +++ /dev/null @@ -1,382 +0,0 @@ -import { t, Trans } from "@lingui/macro"; -import { geoCentroid } from "d3"; -import React, { memo, useEffect, useMemo, useState } from "react"; -import { Box, Flex } from "theme-ui"; -import { - feature as topojsonFeature, - mesh as topojsonMesh, -} from "topojson-client"; -import { Select } from "../../components/form"; -import { HintBlue, LoadingOverlay, NoDataHint } from "../../components/hint"; -import { MapFields, PaletteType } from "../../configurator"; -import { ControlSection } from "../../configurator/components/chart-controls/section"; -import { Observation } from "../../domain/data"; -import { DimensionMetaDataFragment } from "../../graphql/query-hooks"; -import { ChartContainer } from "../shared/containers"; -import { MapComponent } from "./map"; -import { MapLegend } from "./map-legend"; -import { GeoData, MapChart } from "./map-state"; -import { MapTooltip } from "./map-tooltip"; -import { Tab } from "./prototype-components"; -import { PrototypeRightControls } from "./prototype-right-controls"; - -type GeoDataState = - | { - state: "fetching"; - } - | { - state: "error"; - } - | (GeoData & { state: "loaded" }); - -type DataState = - | { - state: "fetching"; - } - | { - state: "error"; - } - | { - state: "loaded"; - ds: Observation[]; - }; - -export const ChartMapVisualization = () => { - const [geoData, setGeoData] = useState({ state: "fetching" }); - const [dataset, loadDataset] = useState({ state: "fetching" }); - - useEffect(() => { - const loadGeoData = async () => { - try { - const res = await fetch(`/topojson/ch-2020.json`); - const topo = await res.json(); - - const cantons = topojsonFeature(topo, topo.objects.cantons); - const cantonMesh = topojsonMesh(topo, topo.objects.cantons); - const lakes = topojsonFeature(topo, topo.objects.lakes); - const cantonCentroids = (cantons as $FixMe).features.map( - (c: $FixMe) => ({ - id: c.id, - coordinates: geoCentroid(c), - }) - ); - setGeoData({ - state: "loaded", - cantons, - cantonMesh, - cantonCentroids, - lakes, - }); - } catch (e) { - setGeoData({ state: "error" }); - } - }; - loadGeoData(); - }, []); - - useEffect(() => { - const loadData = async () => { - try { - const res = await fetch(`/map-data/tidy/holzernte.json`); - const ds = await res.json(); - - loadDataset({ - state: "loaded", - ds, - }); - } catch (e) { - loadDataset({ state: "error" }); - } - }; - loadData(); - }, []); - - if (geoData.state === "fetching" || dataset.state === "fetching") { - return ; - } else if (geoData.state === "error" || dataset.state === "error") { - return ; - } else { - const dimensions = Object.keys(dataset.ds[0]) - .filter((d) => d.startsWith("D_")) - .map((d) => ({ - __typename: "NominalDimension", - iri: d, - label: d, - dimensionValues: [...new Set(dataset.ds.map((datum) => datum[d]))], - })) as Array; - const measures = Object.keys(dataset.ds[0]) - .filter((d) => d.startsWith("M_")) - .map((d) => ({ - __typename: "Measure", - iri: d, - label: d, - })) as DimensionMetaDataFragment[]; - const attributes = Object.keys(dataset.ds[0]) - .filter((d) => d.startsWith("A_")) - .map((d) => ({ - __typename: "NominalDimension", - iri: d, - label: d, - })) as DimensionMetaDataFragment[]; - - return ( - - ); - } -}; - -export type Control = "baseLayer" | "areaLayer" | "symbolLayer"; -export type ActiveLayer = { - relief: boolean; - lakes: boolean; - areaLayer: boolean; - symbolLayer: boolean; -}; -export const ChartMapPrototype = ({ - dataset, - features, - dimensions, - measures, - attributes, -}: { - dataset: Observation[]; - features: GeoData; - dimensions: Array; - measures: DimensionMetaDataFragment[]; - attributes: DimensionMetaDataFragment[]; -}) => { - const [activeLayers, setActiveLayers] = useState({ - relief: true, - lakes: true, - areaLayer: false, - symbolLayer: false, - }); - const [activeControl, setActiveControl] = useState("baseLayer"); - const [palette, setPalette] = useState("oranges"); - const [nbClass, setNbClass] = useState(5); - const [paletteType, setPaletteType] = useState("continuous"); - const [measure, setMeasure] = useState(measures[0].iri); - const [symbolMeasure, setSymbolMeasure] = useState(measures[0].iri); - const [filters, setFilters] = useState<{ [x: string]: string }>( - dimensions.reduce( - (obj, dim, i) => ({ ...obj, [dim.iri]: dim.dimensionValues[0] }), - {} - ) - ); - - const updateActiveLayers = (layerKey: keyof ActiveLayer) => { - setActiveLayers({ - ...activeLayers, - ...{ [layerKey]: !activeLayers[layerKey] }, - }); - }; - const updateFilters = (filterKey: string, filterValue: string) => { - setFilters({ ...filters, ...{ [filterKey]: filterValue } }); - }; - - // Apply filters to data used on the map - const data = useMemo(() => { - const filterfunctions = Object.keys(filters).map( - (filterKey) => (x: Observation) => x[filterKey] === filters[filterKey] - ); - return filterfunctions.reduce((d, f) => d.filter(f), dataset); - }, [dataset, filters]); - - return ( - <> - - - - - Layers - - setActiveControl(v)} - iconName="mapMaptype" - upperLabel={""} - lowerLabel={t({ - id: "chart.map.layers.base", - message: "Base Layer", - })} - checked={activeControl === "baseLayer"} - disabled={false} - /> - setActiveControl(v)} - iconName="mapRegions" - upperLabel={""} - lowerLabel={t({ - id: "chart.map.layers.area", - message: "Area Layer", - })} - checked={activeControl === "areaLayer"} - disabled={false} - /> - setActiveControl(v)} - iconName="mapSymbols" - upperLabel={""} - lowerLabel={t({ - id: "chart.map.layers.symbol", - message: "Symbol Layer", - })} - checked={activeControl === "symbolLayer"} - disabled={false} - /> - - - - - - Data Filters - - - {dimensions.map((dim) => ( - - ({ - value: m.iri, - label: m.label.split("_")[1], - }))} - onChange={(e) => setMeasure(e.currentTarget.value)} - /> - - - - - - - - - - - { - setPaletteType(e.currentTarget.value as PaletteType); - }} - /> - - - setPaletteType(e.currentTarget.value as PaletteType) - } - /> - - setPaletteType(e.currentTarget.value as PaletteType) - } - /> - - setPaletteType(e.currentTarget.value as PaletteType) - } - /> - - - - - ); - } else if (activeControl === "symbolLayer") { - return ( - <> - - - updateActiveLayers("symbolLayer")} - /> - - - - -