import React, { RefObject, memo, useEffect, useRef, useState } from 'react' import * as d3 from 'd3' import * as topojson from 'topojson-client' import styled from 'styled-components' import ZoomOutIcon from './Assets/ZoomOut' import ZoomInIcon from './Assets/ZoomIn' import FullScreenIcon from './Assets/FullScreen' import ExitFullScreenIcon from './Assets/ExitFullScreen' import { SwissMapProps } from './swissMap' import { useIntersectionObserver, useWindowSize } from 'usehooks-ts' const clamp = (num: number, min: number, max: number) => Math.min(Math.max(num, min), max) const INITIAL_HEIGHT = 680 const INITIAL_WIDTH = 975 const EXTENT_PADDING = 100 const TOOLTIP_DISTANCE_TO_CURSOR = 10 const ChoroplethMap = ({ municipalityData, propertyNameToCompare, idPropertyName = 'bfsId' as keyof T, customValueAccessFunction, minValue, maxValue, colorFunction, topoJsonYear = 2022, topoJson, topoJsonObjectName = 'municipalities', activeMunicipalityId = 0, newActiveMunicipalityIdHandler, hoverHandler, isZoomable = true, zoomIntoActiveMunicipality = false, zoomDistance = 5, fullscreenChangeHandler, isFullscreen, fullscreenHint = 'Vollbild', disableTouchOnMobile, colorPalette = ['#296b8e', 'lightgrey', '#9e272f'], activeStrokeWidth = '2', activeStrokeColor = '#ffff', hoverStrokeWidth = '2', hoverStrokeColor = '#ffff', municipalityStrokeColor = '#aaa', municipalityStrokeWidth = '0.5', municipalityFallbackFillColor = '#bfbfbf', lakeFillColor = 'rgb(239, 236, 224)', lakeStrokeColor = 'none', lakeStrokeWidth = '0', nationalBorderColor = 'none', nationalBorderWidth = '0', cantonStrokeColor = 'rgba(0,0,0,0.4)', cantonStrokeWidth = '1', toolTipRef, }: { toolTipRef: RefObject } & SwissMapProps) => { const [chTopoJSON, setChTopoJSON] = useState() const [path, setPath] = useState() const [geoJsonMuni, setGeoJsonMuni] = useState() const [isZoomedIn, setIsZoomedIn] = useState(false) const zoomLevel = useRef(1) const [height, setHeight] = useState(INITIAL_HEIGHT) const [width, setWidth] = useState(INITIAL_WIDTH) const [activeMunicipalitySelection, setActiveMunicipalitySelection] = useState | null>(null) const { height: windowHeight } = useWindowSize() // Refs const { isIntersecting, ref: intersectingRef } = useIntersectionObserver() const controlsAreVisible = isIntersecting const countryBorderRef = useRef(null) const municipalityLayerRef = useRef(null) const highlightLayerRef = useRef(null) const swissChoroplethMapRef = useRef(null) const svgRef = useRef(null) const defaultColorFunction = (value: any) => { return d3.scaleLinear() .domain([minValue, (minValue + maxValue) / 2, maxValue]) .range(colorPalette)(clamp(+value, minValue, maxValue)) } const color = colorFunction ?? defaultColorFunction const defaultAccessFunction = ( municipalityId: string | number | undefined, municipalityData: T[], ) => { const foundMuni = municipalityData.find( (muni) => muni[idPropertyName] === municipalityId, ) if (propertyNameToCompare) { return foundMuni?.[propertyNameToCompare] } return foundMuni } const valueAccessFunction = customValueAccessFunction ?? defaultAccessFunction const zoomed = (event: any) => { const { transform } = event zoomLevel.current = transform.k setIsZoomedIn(transform.k > 1) event.sourceEvent?.stopImmediatePropagation() d3.select(svgRef.current).selectChildren().attr('transform', transform) } const zoom = d3 .zoom() .filter((event) => { const isTouchScrolling = event?.type === 'touchstart' && event?.touches.length === 1 && zoomLevel.current <= 1 return ( !(!event?.ctrlKey && event?.type === 'wheel' && !isFullscreen) && !isTouchScrolling ) }) .scaleExtent([1, zoomDistance + 3]) .translateExtent([ [0 - EXTENT_PADDING, 0 - EXTENT_PADDING], [INITIAL_WIDTH + EXTENT_PADDING, INITIAL_HEIGHT + EXTENT_PADDING], ]) .on('zoom', zoomed) d3.select(svgRef.current).call(zoom as any) const setTopoJson = (newTopoJson: any) => { setGeoJsonMuni( topojson.feature(newTopoJson, newTopoJson.objects[topoJsonObjectName]), ) setChTopoJSON(newTopoJson) const projection = d3 .geoMercator() .rotate([0, 0]) .center([8.23, 46.885]) .scale(11980) .translate([width / 2, height / 2]) .precision(0.1) setPath(() => d3.geoPath(projection)) } function repositionToolTip(mouseX: number, mouseY: number) { const toolTipElement = d3.select(toolTipRef.current).node() as HTMLElement const widthOfTooltip = toolTipElement?.clientWidth const windowWidth = document.documentElement.clientWidth const toolTipShouldBeLeft = widthOfTooltip + mouseX + TOOLTIP_DISTANCE_TO_CURSOR > windowWidth if (!toolTipShouldBeLeft) { d3.select(toolTipRef.current) .style('left', mouseX + TOOLTIP_DISTANCE_TO_CURSOR + 'px') .style('top', mouseY + TOOLTIP_DISTANCE_TO_CURSOR + 'px') } else { d3.select(toolTipRef.current) .style( 'left', mouseX - widthOfTooltip - TOOLTIP_DISTANCE_TO_CURSOR + 'px', ) .style('top', mouseY + TOOLTIP_DISTANCE_TO_CURSOR + 'px') } } function addHoverInteractions() { const mapElement = d3 .select(swissChoroplethMapRef.current) .node() as HTMLElement if ( mapElement?.clientWidth > 600 && d3.select(municipalityLayerRef.current).selectChildren().nodes().length > 0 ) { d3.select(municipalityLayerRef.current) .selectChildren() .on('mouseover', function (this: any, event: MouseEvent) { const foundMunicipality = municipalityData.find( (muni) => muni[idPropertyName] == d3.select(this).attr('bfsId'), ) foundMunicipality && hoverHandler?.(foundMunicipality) // put hovered municipality on top d3.select(this).raise() // put active municipality over the hovered one d3.select(municipalityLayerRef.current) .selectChild('.active-municipality') .raise() // check if event is from a touch device we don't want to show the tooltip if (navigator.maxTouchPoints > 0) { return } if (event.type === 'touchstart') { return } d3.select(toolTipRef.current).style('opacity', 1) repositionToolTip(event.clientX, event.clientY) }) .on('mouseout', function () { d3.select(toolTipRef.current).style('opacity', 0) }) .on('mousemove', function (event: MouseEvent) { repositionToolTip(event.clientX, event.clientY) }) window.addEventListener( 'scroll', function () { d3.select(toolTipRef.current).style('opacity', 0) }, false, ) } } function zoomTo( selection: d3.Selection, ) { const d = geoJsonMuni.features.find( (muni: any) => +muni.id === +selection.attr('bfsId'), ) const [[x0, y0], [x1, y1]] = path!.bounds(d) d3.select(svgRef.current) .transition() .duration(750) .call( zoom.transform as any, d3.zoomIdentity .translate(INITIAL_WIDTH / 2, INITIAL_HEIGHT / 2) .scale( Math.min( zoomDistance, 0.9 / Math.max((x1 - x0) / INITIAL_WIDTH, (y1 - y0) / INITIAL_HEIGHT), ), ) .translate(-(x0 + x1) / 2, -(y0 + y1) / 2), ) setIsZoomedIn(true) } function zoomInToWhereIam() { const svgSelection = d3.select(svgRef.current) if (!svgSelection || !zoom) return svgSelection.transition().call(zoom.scaleBy as any, 2) } function zoomOut() { const svgSelection = d3.select(svgRef.current) if (path && svgSelection) { svgSelection .transition() .duration(750) .call( zoom.transform as any, d3.zoomIdentity, d3 .zoomTransform(svgSelection.node()!) .invert([width / 2, height / 2]), ) setIsZoomedIn(false) } } function zoomToActiveMunicipality(passedActiveMunicipalityId?: number) { const bfsIdToZoomTo = passedActiveMunicipalityId || activeMunicipalityId if (bfsIdToZoomTo) { activeMunicipalitySelection && zoomTo(activeMunicipalitySelection!) } } function highlightActiveMunicipality() { activeMunicipalitySelection?.attr('class', 'municipality') const muniToHighlight = d3 .select(municipalityLayerRef.current) .selectChild('#bfsId-' + activeMunicipalityId) ?.attr('class', 'active-municipality') if (activeMunicipalityId && muniToHighlight.node()) { muniToHighlight.raise() setActiveMunicipalitySelection(muniToHighlight) isZoomedIn && zoomTo(muniToHighlight) } else { zoomOut() } } function addMunicipalityPaths() { if (path) { d3.select(municipalityLayerRef.current).selectAll('*').remove() d3.select(municipalityLayerRef.current) .selectAll('*') .data(geoJsonMuni.features) .enter() .append('path') .attr('d', (feature: any) => { return path(feature) }) .attr('class', 'municipality') .attr('id', (feature: any) => 'bfsId-' + feature.id) .attr('bfsId', (feature: any) => feature.id) .on('click', function (this: any, event: PointerEvent, feature: any) { // check if it is touch event or not d3.select(this).raise() if (window.innerWidth > 600 || !disableTouchOnMobile) { newActiveMunicipalityIdHandler?.( feature.id ?? feature.properties.id, ) } }) colorMunicipalities() addHoverInteractions() } } function fullscreenButtonPressed() { setHeight(window.innerHeight) setWidth(swissChoroplethMapRef.current?.clientWidth ?? 0) fullscreenChangeHandler?.(!isFullscreen) setTimeout(() => { svgRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center', }) }, 100) } useEffect(() => { // if we already have the (local) topojson, we don't need to fetch it if (topoJson) { console.log('unsing topoJson passed as prop') setTopoJson(topoJson) return } fetch( 'https://unpkg.com/swiss-maps/' + topoJsonYear + '/ch-combined.json', ).then((res) => res.json().then((parsed) => { console.log('using topoJson fetched topoJsonYear: ', topoJsonYear) setTopoJson(parsed) }), ) }, [topoJson]) useEffect(() => { if (path) { addMunicipalityPaths() } activeMunicipalityId && zoomIntoActiveMunicipality && zoomToActiveMunicipality() console.log('useEffect: [geoJsonMuni, path]') }, [geoJsonMuni, path]) useEffect(() => { zoomIntoActiveMunicipality ? zoomToActiveMunicipality() : zoomOut() }, [zoomIntoActiveMunicipality]) const colorMunicipalities = () => { d3.select(municipalityLayerRef.current) .selectAll('path') .attr('fill', (feature: any) => { const valueToCompare = valueAccessFunction(feature.id, municipalityData) let newColor if ( valueToCompare !== undefined && valueToCompare !== '' && valueToCompare !== null ) { try { newColor = d3.color(color(valueToCompare as any)) } catch (error) { console.warn('Error coloring municipality: ', error) newColor = d3.color(municipalityFallbackFillColor) } } else { newColor = d3.color(municipalityFallbackFillColor) } return newColor?.toString() ?? 'transparent' }) } useEffect(() => { highlightActiveMunicipality() console.log('useEffect: [activeMunicipalityId]') }, [activeMunicipalityId]) useEffect(() => { if (activeMunicipalitySelection && zoomIntoActiveMunicipality) { zoomToActiveMunicipality() } console.log('useEffect: [activeMunicipalitySelection]') }, [activeMunicipalitySelection]) useEffect(() => { colorMunicipalities() addHoverInteractions() console.log('useEffect: [municipalityData, propertyNameToCompare, minValue, maxValue, customValueAccessFunction]') }, [municipalityData, propertyNameToCompare, minValue, maxValue, customValueAccessFunction]) useEffect(() => { // handle change of height if window is resized and we are in fullscreen if (isFullscreen) { setHeight(windowHeight) } }, [windowHeight]) console.log('rerender Map') return (
{chTopoJSON && path && municipalityData && ( {chTopoJSON.objects.cantons && ( a !== b, ), ) as string } /> )} {chTopoJSON.objects.country && ( )} {chTopoJSON.objects.lakes && ( )} )} {isZoomable && ( <> zoomOut()} /> = zoomDistance ? 'disabled' : undefined } onClick={() => { setIsZoomedIn(true) activeMunicipalityId ? zoomToActiveMunicipality() : zoomInToWhereIam() }} /> )} {controlsAreVisible && fullscreenHint && ( {fullscreenHint} )} {isFullscreen ? ( ) : ( )}
) } const FullscreenButtonWrapper = styled.span` line-height: 0; @media (min-width: 1200px) { display: none; } ` const StyledSVG = styled.svg<{ $activeStrokeWidth: string $activeStrokeColor: string $hoverStrokeWidth: string $hoverStrokeColor: string }>` #municipality-layer { path { vector-effect: non-scaling-stroke; } } .active-municipality { stroke: ${({ $activeStrokeColor }) => $activeStrokeColor}; stroke-width: ${({ $activeStrokeWidth }) => $activeStrokeWidth}; } .municipality { @media (hover: hover) { &:hover { stroke: ${({ $hoverStrokeColor }) => $hoverStrokeColor}; stroke-width: ${({ $hoverStrokeWidth }) => $hoverStrokeWidth}; } } } ` const Hint = styled.div` line-height: 2; position: absolute; color: var(--text-color); right: 12px; top: 0; transform: translate(50%, -150%); background: var(--hint-background); border-radius: 0.5em; padding: 0 0.9em; box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05); font-size: 0.6em; text-align: center; z-index: 100; transition: opacity 0.2s ease-in-out; pointer-events: none; // prevent line breaks white-space: nowrap; opacity: 0; animation: riseUp 5s ease-in-out; @keyframes riseUp { 10% { opacity: 0; transform: translateX(50%) translateY(1em); } 15% { opacity: 1; transform: translateX(50%) translateY(-150%); } 95% { opacity: 1; transform: translateX(50%) translateY(-150%); } 100% { opacity: 0; transform: translateX(50%) translateY(1em); } } &:after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 0.5em solid transparent; border-top-color: var(--hint-background); } ` const MapControls = styled.div<{ $isFullscreen?: boolean }>` --brandblue-main: #202346; --gray-4: #d4d4d4; --gray-5: #e9e9e9; --gray-6: #ffffff; display: flex; justify-content: flex-end; margin-right: ${({ $isFullscreen: isFullscreen }) => isFullscreen ? '1.5em' : '1em'}; padding-bottom: ${({ $isFullscreen: isFullscreen }) => isFullscreen ? '1.5em' : '0em'}; transition: padding-bottom 0.3s ease-in-out; pointer-events: none; color: var(--brandblue-main); position: sticky; z-index: 101; gap: 0.5em; bottom: 0; svg { -webkit-tap-highlight-color: rgba(0, 0, 0, 0); @media (min-width: 535px) { &:hover { background-color: var(--gray-6); } } @media (max-width: 535px) { width: 1.5em; height: 1.5em; } &.disabled { opacity: 0.5; pointer-events: none; } transition: all 0.3s ease-in-out; pointer-events: all; cursor: pointer; background-color: var(--gray-5); border-radius: 10%; } ` export default memo( ChoroplethMap, (prevProps, nextProps) => prevProps.municipalityData === nextProps.municipalityData && prevProps.activeMunicipalityId === nextProps.activeMunicipalityId && prevProps.minValue === nextProps.minValue && prevProps.maxValue === nextProps.maxValue && prevProps.customValueAccessFunction === nextProps.customValueAccessFunction && prevProps.topoJsonYear === nextProps.topoJsonYear && prevProps.isFullscreen === nextProps.isFullscreen && prevProps.propertyNameToCompare === nextProps.propertyNameToCompare && prevProps.isZoomable === nextProps.isZoomable && prevProps.zoomIntoActiveMunicipality === nextProps.zoomIntoActiveMunicipality && prevProps.zoomDistance === nextProps.zoomDistance, ) as typeof ChoroplethMap