From a2c662166975004d9317decb28c6398e22aa9a75 Mon Sep 17 00:00:00 2001 From: Logan Williams Date: Tue, 6 Jan 2026 08:14:11 -0500 Subject: [PATCH] Supporting "highlighting" events via URL parameters (#85) * Synchronize map state to URL * Add support for highlighting specific events based on URL parameters --- src/components/space/carto/Map.jsx | 2 + src/components/space/carto/atoms/Clusters.jsx | 56 ++++++++++++++++--- src/components/space/carto/atoms/Events.jsx | 44 ++++++++++++++- src/components/time/Timeline.jsx | 2 + src/components/time/atoms/Events.jsx | 15 ++++- src/store/initial.js | 2 +- src/store/plugins/urlState/schema.js | 13 +++++ 7 files changed, 121 insertions(+), 13 deletions(-) diff --git a/src/components/space/carto/Map.jsx b/src/components/space/carto/Map.jsx index bf6858f..a097f3a 100644 --- a/src/components/space/carto/Map.jsx +++ b/src/components/space/carto/Map.jsx @@ -469,6 +469,7 @@ class Map extends Component { categories={this.props.domain.categories} projectPoint={this.projectPoint} selected={this.props.app.selected} + highlighted={this.props.app.highlighted} narrative={this.props.app.narrative} onSelect={this.props.methods.onSelect} getCategoryColor={this.props.methods.getCategoryColor} @@ -495,6 +496,7 @@ class Map extends Component { coloringSet={this.props.app.coloringSet} getClusterChildren={this.getClusterChildren} filterColors={this.props.ui.filterColors} + highlighted={this.props.app.highlighted} /> ); } diff --git a/src/components/space/carto/atoms/Clusters.jsx b/src/components/space/carto/atoms/Clusters.jsx index e2c4db5..7465927 100644 --- a/src/components/space/carto/atoms/Clusters.jsx +++ b/src/components/space/carto/atoms/Clusters.jsx @@ -12,6 +12,8 @@ import { calculateTotalClusterPoints, } from "../../../../common/utilities"; +const HIGHLIGHT_COLOR = "#E31A1B"; + const DefsClusters = () => ( @@ -32,6 +34,7 @@ function Cluster({ getClusterChildren, coloringSet, filterColors, + highlighted, }) { /** { @@ -50,10 +53,48 @@ function Cluster({ const { cluster_id: clusterId } = cluster.properties; const individualChildren = getClusterChildren(clusterId); - const colorPercentages = calculateColorPercentages( - individualChildren, - coloringSet - ); + + // Calculate percentage of highlighted events in this cluster + const allEvents = individualChildren.flatMap((loc) => loc.events); + const totalEvents = allEvents.length; + const highlightedEvents = + highlighted && highlighted.length > 0 + ? allEvents.filter((event) => highlighted.includes(event.civId)) + : []; + const highlightedCount = highlightedEvents.length; + const highlightedPercent = totalEvents > 0 ? highlightedCount / totalEvents : 0; + + let colorPercentMap; + if (highlightedPercent === 1) { + // All events are highlighted + colorPercentMap = { [HIGHLIGHT_COLOR]: 1 }; + } else if (highlightedPercent > 0) { + // Mix of highlighted and non-highlighted events + const nonHighlightedChildren = individualChildren.map((loc) => ({ + ...loc, + events: loc.events.filter( + (event) => !highlighted || !highlighted.includes(event.civId) + ), + })).filter((loc) => loc.events.length > 0); + + const colorPercentages = calculateColorPercentages( + nonHighlightedChildren, + coloringSet + ); + // Scale down the category percentages and add highlight percentage + const scaledPercentages = colorPercentages.map( + (p) => p * (1 - highlightedPercent) + ); + colorPercentMap = zipColorsToPercentages(filterColors, scaledPercentages); + colorPercentMap[HIGHLIGHT_COLOR] = highlightedPercent; + } else { + // No highlighted events + const colorPercentages = calculateColorPercentages( + individualChildren, + coloringSet + ); + colorPercentMap = zipColorsToPercentages(filterColors, colorPercentages); + } const { coordinates } = cluster.geometry; const [longitude, latitude] = coordinates; @@ -72,10 +113,7 @@ function Cluster({ > 0 + ? location.events.filter((event) => highlighted.includes(event.civId)) + : []; + const highlightedCount = highlightedEvents.length; + const highlightedPercent = highlightedCount / totalEvents; + + // Get non-highlighted events for category color calculation + const nonHighlightedEvents = location.events.filter( + (event) => !highlighted || !highlighted.includes(event.civId) + ); + + let colorPercentMap; + if (highlightedPercent === 1) { + // All events are highlighted + colorPercentMap = { [HIGHLIGHT_COLOR]: 1 }; + } else if (highlightedPercent > 0) { + // Mix of highlighted and non-highlighted events + const nonHighlightedLocation = { ...location, events: nonHighlightedEvents }; + const colorPercentages = calculateColorPercentages( + [nonHighlightedLocation], + coloringSet + ); + // Scale down the category percentages and add highlight percentage + const scaledPercentages = colorPercentages.map( + (p) => p * (1 - highlightedPercent) + ); + colorPercentMap = zipColorsToPercentages(filterColors, scaledPercentages); + colorPercentMap[HIGHLIGHT_COLOR] = highlightedPercent; + } else { + // No highlighted events + const colorPercentages = calculateColorPercentages([location], coloringSet); + colorPercentMap = zipColorsToPercentages(filterColors, colorPercentages); + } + return ( @@ -510,6 +511,7 @@ function mapStateToProps(state) { }, app: { selected: state.app.selected, + highlighted: state.app.highlighted, language: state.app.language, narrative: state.app.associations.narrative, coloringSet: state.app.associations.coloringSet, diff --git a/src/components/time/atoms/Events.jsx b/src/components/time/atoms/Events.jsx index 42cb080..24df5d0 100644 --- a/src/components/time/atoms/Events.jsx +++ b/src/components/time/atoms/Events.jsx @@ -15,6 +15,8 @@ import { } from "../../../common/utilities"; import { AVAILABLE_SHAPES } from "../../../common/constants"; +const HIGHLIGHT_COLOR = "#E31A1B"; + function renderDot(event, styles, props) { const colorPercentages = calculateColorPercentages( [event], @@ -144,6 +146,7 @@ const TimelineEvents = ({ eventRadius, filterColors, coloringSet, + highlighted, }) => { const narIds = narrative ? narrative.steps.map((s) => s.id) : []; @@ -178,13 +181,23 @@ const TimelineEvents = ({ } } + // Check if this event is highlighted + const isHighlighted = + highlighted && + highlighted.length > 0 && + highlighted.includes(event.civId); + // if an event has multiple categories, it should be rendered on each of // those timelines: so we create as many event 'shadows' as there are // categories const evShadows = getEventCategories(event, categories).map((cat) => { const y = getY({ ...event, category: cat }); - const colour = event.colour ? event.colour : getCategoryColor(cat.title); + const colour = isHighlighted + ? HIGHLIGHT_COLOR + : event.colour + ? event.colour + : getCategoryColor(cat.title); const styles = { fill: colour, diff --git a/src/store/initial.js b/src/store/initial.js index d6b4861..470b5bd 100644 --- a/src/store/initial.js +++ b/src/store/initial.js @@ -52,7 +52,7 @@ const initial = { errors: { source: false, }, - highlighted: null, + highlighted: [], selected: [], source: null, associations: { diff --git a/src/store/plugins/urlState/schema.js b/src/store/plugins/urlState/schema.js index b4ac325..1ac50e4 100644 --- a/src/store/plugins/urlState/schema.js +++ b/src/store/plugins/urlState/schema.js @@ -70,6 +70,19 @@ export const SCHEMA = Object.freeze({ } }, }, + hid: { + key: "hid", + trigger: null, // Read-only from URL, no action triggers update + type: SCHEMA_TYPES.STRING_ARRAY, + dehydrate() { + return []; // Never update URL from state + }, + rehydrate(nextState, { hid }) { + if (hid?.length) { + nextState.app.highlighted = hid; + } + }, + }, range: { key: "range", trigger: UPDATE_TIMERANGE,