From 3f752fd2bbdf89b90105f53b1e05654c9bc3fde2 Mon Sep 17 00:00:00 2001 From: Carl Higgs Date: Tue, 1 Oct 2024 15:47:57 +1000 Subject: [PATCH] towards addressing #27 and hopefully #30 --- app/index.html | 2 +- app/src/App.tsx | 30 ++-- app/src/components/share.tsx | 15 +- app/src/components/utilities.tsx | 33 +++++ app/src/components/vis/map/map.tsx | 129 +++++++++--------- .../vis/map/map_scenario_settings.tsx | 2 +- app/src/components/vis/stories/stories.json | 2 +- 7 files changed, 126 insertions(+), 87 deletions(-) diff --git a/app/index.html b/app/index.html index 1dc1723..9954386 100644 --- a/app/index.html +++ b/app/index.html @@ -13,7 +13,7 @@ manifest.json provides metadata used when your web app is installed on a user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ --> - + Transport & Health Impacts diff --git a/app/src/App.tsx b/app/src/App.tsx index 876ffd1..6a340d7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,7 +3,7 @@ import Navbar from './components/navbar'; import './App.css'; import { Amplify } from 'aws-amplify'; import { Authenticator } from '@aws-amplify/ui-react'; -import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { useLocation } from "react-router-dom"; import '@aws-amplify/ui-react/styles.css'; import awsconfig from '../amplify_outputs.json'; import Error404 from "./components/404-page"; @@ -52,16 +52,13 @@ const theme = createTheme({ export function useScrollToAnchor() { - const [searchParams, setSearchParams] = useSearchParams(); - const location = useLocation(); - const current_location = window.location; - const navigate = useNavigate(); + const location = useLocation(); useEffect(() => { - if (current_location.hash === '') window.scrollTo(0, 0) + if (location.hash === '') window.scrollTo(0, 0) else { setTimeout(() => { - const id = current_location.hash.replace('#', '') + const id = location.hash.replace('#', '') const element = document.getElementById(id) if (element) { element.scrollIntoView({ @@ -72,21 +69,24 @@ export function useScrollToAnchor() { } }, 0) } + let protocol = new Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile); return () => { maplibregl.removeProtocol("pmtiles"); - console.log(location); - console.log(current_location.href); - console.log(searchParams.size!==0); - if (current_location.pathname !== '/map' && searchParams.size!==0) { - setSearchParams([]); - console.log(searchParams); - } + // console.log(location); + // console.log(current_location.pathname+current_location.hash); + // if (!urlUpdated && location.pathname !== '/map' || current_location.pathname !== '/map') { + // // console.log('test'); + // // Clear query strings by updating the URL without triggering a navigation + // const newUrl = `${location.pathname}${location.hash}`; + // window.history.replaceState(null, '', newUrl); + // setUrlUpdated(true); + // } }; - }, [current_location, location, navigate]) + }, [location]); } const App: FC = () => { diff --git a/app/src/components/share.tsx b/app/src/components/share.tsx index e53c496..51b5b9b 100644 --- a/app/src/components/share.tsx +++ b/app/src/components/share.tsx @@ -2,13 +2,22 @@ import React from 'react'; import { Fab, Tooltip } from '@mui/material'; import ShareIcon from '@mui/icons-material/Share'; import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar'; +import { FocusFeature} from './utilities'; -export function ShareURL() { +interface ShareURLProps { + focusFeature?: FocusFeature; + } + +export function ShareURL({ focusFeature }: ShareURLProps) { const [open, setOpen] = React.useState(false); const handleClick = () => { - navigator.clipboard.writeText(window.location.href); - console.log('Copied to clipboard:', window.location.href); + const baseUrl = `${window.location.origin}${window.location.pathname}`; + const queryString = focusFeature ? `?${focusFeature.getQueryString()}` : ''; + const shareUrl = `${baseUrl}${queryString}`; + + navigator.clipboard.writeText(shareUrl); + console.log('Copied to clipboard:', shareUrl); setOpen(true); }; diff --git a/app/src/components/utilities.tsx b/app/src/components/utilities.tsx index a588fe9..cb67fb1 100644 --- a/app/src/components/utilities.tsx +++ b/app/src/components/utilities.tsx @@ -2,6 +2,37 @@ export const capitalString = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); } +export class FocusFeature { + private features: { [key: string]: string }; + + constructor() { + this.features = {}; + } + + update(params: { [key: string]: string }) { + Object.entries(params).forEach(([queryKey, queryValue]) => { + const oldQuery = this.features[queryKey] ?? ''; + if (queryValue === oldQuery) return; + + if (queryValue && queryValue !== '') { + this.features[queryKey] = queryValue; + } else { + delete this.features[queryKey]; + } + }); + } + + getAll() { + return this.features; + } + + getQueryString() { + const queryParams = new URLSearchParams(this.features); + console.log(queryParams.toString()) + return queryParams.toString(); + } +} + export function updateSearchParams(params: { [key: string]: string }) { const currentSearchParams = new URLSearchParams(window.location.search); @@ -23,3 +54,5 @@ export function updateSearchParams(params: { [key: string]: string }) { // Manually update the URL without triggering a navigation window.history.replaceState(null, '', newUrl); } + + diff --git a/app/src/components/vis/map/map.tsx b/app/src/components/vis/map/map.tsx index 5f92751..0f7570c 100644 --- a/app/src/components/vis/map/map.tsx +++ b/app/src/components/vis/map/map.tsx @@ -1,9 +1,9 @@ -import { FC, useRef, useEffect, useState, useCallback } from 'react'; +import { FC, useRef, useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { updateSearchParams } from '../../utilities'; +import { FocusFeature} from '../../utilities'; import cities from '../stories/cities.json'; import stories from '../stories/stories.json'; -import maplibregl, { LngLatLike, MapMouseEvent, LayerSpecification as OriginalLayerSpecification } from 'maplibre-gl'; +import maplibregl, { LngLatLike, MapMouseEvent } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; // import * as pmtiles from "pmtiles"; import layers from "protomaps-themes-base"; @@ -26,6 +26,7 @@ import { Steps, Hints } from 'intro.js-react'; import 'intro.js/introjs.css'; import { useSearchParams } from 'react-router-dom'; import ScenarioSettings from './map_scenario_settings'; +import { ShareURL } from '../../share'; // const protocol = new pmtiles.Protocol(); @@ -44,37 +45,28 @@ interface MapProps {} const Map: FC = (): JSX.Element => { const [searchParams, _] = useSearchParams(); + const [focusFeature, setFocusFeature] = useState(new FocusFeature()); + const featureLoaded = useRef(false); const scenario_setting = new ScenarioSettings(); scenario_setting.initialize(searchParams, stories, cities); - - // const formatPopup = getFormatPopup(scenario.poup) + const initial_query = Object.fromEntries(searchParams.entries()); + focusFeature.update(initial_query); const scenario = scenario_setting.get(); - console.log(scenario); const mapContainer = useRef(null); const map = useRef(null); - const [lat] = useState(Number(scenario['lat'])); - const [lng] = useState(Number(scenario['lng'])); - const [zoom] = useState(Number(scenario['zoom'])); - let url_feature = { - source: searchParams.get('source'), - layer: searchParams.get('layer'), - id: searchParams.get('id'), - xy: searchParams.get('xy')?.split(',').map(parseFloat) as [number, number], - v: searchParams.get('v'), - filtered: searchParams.get('filter'), - } - const [featureLoaded, setFeatureLoaded] = useState(false); - // Melbourne bbox + const [lat] = useState(Number(initial_query['lat'] || scenario['lat'])); + const [lng] = useState(Number(initial_query['lng'] || scenario['lng'])); + const [zoom] = useState(Number(initial_query['zoom'] || scenario['zoom'])); const bounds = new maplibregl.LngLatBounds(scenario['bounds'] as unknown as LngLatLike); let map_layers: string[] = []; const handleMapClick = (e: MapMouseEvent, features:maplibregl.MapGeoJSONFeature[], scenario: { [key: string]: any }) => { if (features[0] && 'id' in features[0]) { // Add parameters to the URL query string - updateSearchParams({ + focusFeature.update({ xy: e.lngLat.lng + ',' + e.lngLat.lat, source: String(features[0]['source']) ?? '', layer: String(features[0]['layer']['id']) ?? '', @@ -86,7 +78,6 @@ const Map: FC = (): JSX.Element => { useEffect(() => { if (map.current) return; // stops map from intializing more than once - map.current = new maplibregl.Map({ container: mapContainer.current!, style: { @@ -166,7 +157,7 @@ const Map: FC = (): JSX.Element => { } } - updateSearchParams({'v': String(selectedVariable) ?? ''}); + focusFeature.update({'v': String(selectedVariable) ?? ''}); }); @@ -196,14 +187,14 @@ const Map: FC = (): JSX.Element => { } }); - updateSearchParams({'filter': className.replace('filtered-', '')}); + focusFeature.update({'filter': className.replace('filtered-', '')}); } else if (className === 'unfiltered') { layer_IDs.forEach((layer_ID: string) => { map.current!.setFilter(layer_ID, null); }); - updateSearchParams({'filter':''}); + focusFeature.update({'filter':''}); } } @@ -223,66 +214,72 @@ const Map: FC = (): JSX.Element => { }); popup.on('close', () => { - updateSearchParams({'source':'','layer':'','id':''}); + focusFeature.update({'source':'','layer':'','id':''}); }) function getFeatureFromURL() { - if (url_feature.v) { - const variableSelect = document.getElementById('variable-select') as HTMLSelectElement; - if (variableSelect && url_feature.v) { - variableSelect.value = url_feature.v; - variableSelect.dispatchEvent(new Event('change')); - } - } + const url_feature = Object.fromEntries(searchParams.entries()); + // console.log('get feature: ', url_feature) + if (url_feature.v) { + const variableSelect = document.getElementById('variable-select') as HTMLSelectElement; + if (variableSelect && url_feature.v) { + variableSelect.value = url_feature.v; + variableSelect.dispatchEvent(new Event('change')); + } + } + if (url_feature.zoom) { + const new_zoom = parseFloat(url_feature.zoom) + map.current!.setZoom(new_zoom); + } - if (url_feature.source && url_feature.layer && url_feature.id && url_feature.xy) { - const features = map.current!.queryRenderedFeatures( - { layers: [url_feature.layer] } - ); - const feature = features!.find((feat) => String(feat.id) === url_feature.id); - if (feature) { - const scenario_layer = scenario.layers.find((x: { id: string; })=> x.id === feature.layer.id) - if ('popup' in scenario_layer) { - formatPopup(feature, url_feature.xy, map, popup, url_feature.layer); - setFeatureLoaded(true); + if (url_feature.source && url_feature.layer && url_feature.id && url_feature.xy) { + const features = map.current!.queryRenderedFeatures( + { layers: [url_feature.layer] } + ); + const feature = features!.find((feat) => String(feat.id) === url_feature.id); + if (feature) { + const scenario_layer = scenario.layers.find((x: { id: string; })=> x.id === feature.layer.id) + if ('popup' in scenario_layer) { + const xy = url_feature.xy.split(',').map(Number) as [number, number]; + formatPopup(feature, xy, map, popup, url_feature.layer); + } + displayFeatureCheck(feature, scenario) } - displayFeatureCheck(feature, scenario) - } - } - // if (url_feature.filtered && !featureLoaded) { - // const legendRow = document.getElementById('legend-row'); - // if (legendRow) { - // // legendRow.className = 'filtered-' + url_feature.filtered; - // const filterCellIndex = url_feature.filtered.split('-')[0]; - // const legendCell = document.getElementById(`legend-cell-${filterCellIndex}`); - // if (legendCell) { - // legendCell.click(); - // } - // } - // } - setFeatureLoaded(true); + } + if (url_feature.filter && !featureLoaded.current) { + // console.log(url_feature.filter) + const legendRow = document.getElementById('legend-row'); + if (legendRow) { + // legendRow.className = 'filtered-' + url_feature.filtered; + const filterCellIndex = url_feature.filter.split('-')[0]; + const legendCell = document.getElementById(`legend-cell-${filterCellIndex}`); + if (legendCell) { + legendCell.click(); + } + } + } + // window.history.replaceState({}, document.title, window.location.pathname); }; map.current!.on('sourcedata', (e) => { - if (e.isSourceLoaded && !featureLoaded) { + if (e.isSourceLoaded && !featureLoaded.current) { getFeatureFromURL() + featureLoaded.current = true; } }); }); map.current!.on('zoomend', function() { const zoomLevel = Math.round(map.current!.getZoom() * 10) / 10; - - updateSearchParams({'zoom': zoomLevel.toString()}); - + focusFeature.update({'zoom': zoomLevel.toString()}); + setFocusFeature(focusFeature); }); map.current!.on('moveend', function() { const center = map.current!.getCenter(); - - updateSearchParams({'lat': center.lat.toFixed(4),'lng': center.lng.toFixed(4)}); - + focusFeature.update({'lat': center.lat.toFixed(4),'lng': center.lng.toFixed(4)}); + setFocusFeature(focusFeature); }); @@ -290,12 +287,12 @@ const Map: FC = (): JSX.Element => { const features = map.current!.queryRenderedFeatures(e.point, { layers: map_layers }); handleMapClick(e, features, scenario); }); - -}, [url_feature.filtered, featureLoaded, map, scenario, updateSearchParams]); +}, [featureLoaded, scenario]); // console.log([lng, lat, zoom, url_feature, featureLoaded]) return (
+ 0) { diff --git a/app/src/components/vis/stories/stories.json b/app/src/components/vis/stories/stories.json index 3725567..8fefc8a 100644 --- a/app/src/components/vis/stories/stories.json +++ b/app/src/components/vis/stories/stories.json @@ -90,7 +90,7 @@ "params": { "city": "Melbourne", "directions": "Select a road segment to view a range of metrics related to suitability for walking and cycling.", - "help": "

Level of Traffic Stress (LTS) for cycling along discrete road segments has been measured specifically for the Victorian policy context. The classification ranges from 1 (lowest stress, for use by all cyclists) to 4 (most stressful, and least suitable for safe cycling). Our implementation of this measure draws on research developed at RMIT by Dr Afshin Jafari and Steve Pemberton (read more).

Multiple variables may contribute to comfort or stress when cycling, including traffic intensity, intersection design, and presence of separated bike paths. However, environmental aspects such as greenery and shade are also factors influencing cycling choices.

Pemberton, S., & Jafari, A. (2024). Cycling safety and comfort (v1.0.1). Zenodo. https://doi.org/10.5281/zenodo.13831295

", + "help": "

Level of Traffic Stress (LTS) for cycling along discrete road segments has been measured specifically for the Victorian policy context. The classification ranges from 1 (lowest stress, for use by all cyclists) to 4 (most stressful, and least suitable for safe cycling). Our implementation of this measure draws on research developed at RMIT by Dr Afshin Jafari and Steve Pemberton (read more).

Multiple variables may contribute to comfort or stress when cycling, including traffic intensity, intersection design, and presence of separated bike paths. However, environmental aspects such as greenery and shade are also factors influencing cycling choices.

Pemberton, S., & Jafari, A. (2024). Cycling safety and comfort (v1.0.1). Zenodo. https://doi.org/10.5281/zenodo.13831295

", "legend_layer": 0, "source": { "network": {