From 2643d3aeb2ce8e5167a0372b3684c83283e78fbb Mon Sep 17 00:00:00 2001 From: matt grogan Date: Mon, 10 Nov 2025 06:17:51 -0600 Subject: [PATCH 1/6] feat: Add timeline filter for temporal edge visualization - Add TimelineFilterType to SDK and gephi-lite packages - Implement timeline filtering logic using Luxon DateTime - Create TimelineFilter component with interactive slider - Integrate timeline filter into filters panel - Add automatic filter selection for date fields on edges - Create sample 'Social Network Timeline' graph with temporal data - Add English translations for timeline filter - Document feature in TIMELINE_FEATURE.md This feature allows users to filter graph edges based on date/timestamp attributes using an interactive slider. Users can visualize how their graph evolves over time by selecting different time ranges. --- TIMELINE_FEATURE.md | 105 ++++++++ package-lock.json | 37 ++- .../samples/Social Network Timeline.json | 229 ++++++++++++++++++ .../GraphFilters/TimelineFilter.tsx | 221 +++++++++++++++++ .../src/components/GraphFilters/index.tsx | 5 +- .../components/modals/SelectFilterModal.tsx | 29 ++- packages/gephi-lite/src/core/filters/types.ts | 1 + packages/gephi-lite/src/core/filters/utils.ts | 17 +- packages/gephi-lite/src/locales/en.json | 6 +- packages/sdk/src/filters/types.ts | 9 + 10 files changed, 644 insertions(+), 15 deletions(-) create mode 100644 TIMELINE_FEATURE.md create mode 100644 packages/gephi-lite/public/samples/Social Network Timeline.json create mode 100644 packages/gephi-lite/src/components/GraphFilters/TimelineFilter.tsx diff --git a/TIMELINE_FEATURE.md b/TIMELINE_FEATURE.md new file mode 100644 index 00000000..563abf3e --- /dev/null +++ b/TIMELINE_FEATURE.md @@ -0,0 +1,105 @@ +# Timeline Filter Feature + +This branch implements a timeline filter feature for Gephi Lite that allows users to filter graph edges based on date/timestamp attributes. + +## Overview + +The timeline filter enables visualization of how a graph changes over time by allowing users to select a time range using a slider. Only edges with timestamps falling within the selected range will be displayed. + +## Features Implemented + +1. **Timeline Filter Type**: A new `TimelineFilterType` that extends the existing filter system to handle temporal data on edges. + +2. **Filtering Logic**: Edge filtering based on date/timestamp values using the Luxon DateTime library for robust date handling. + +3. **Timeline Slider UI**: An interactive slider component similar to the RangeFilter, but specifically designed for temporal data with: + - Visual histogram showing edge distribution over time + - Date range selection + - Min/max date displays in human-readable format + +4. **Filter Panel Integration**: Timeline filter is automatically available when date-type fields are detected on edges. + +5. **Sample Data**: A "Social Network Timeline" sample graph demonstrating the feature with timestamped edges. + +## How to Use + +1. **Load a graph with temporal edge data**: The graph must have edges with a date-type attribute. + +2. **Add a timeline filter**: + - Open the Filters panel + - Click "Add filter" + - Under "Edges fields", select a date attribute + - The timeline filter will be created automatically for date fields on edges + +3. **Adjust the time range**: + - Use the slider to select the desired time range + - The graph will update in real-time to show only edges within that range + - The histogram shows the distribution of edges over time + +4. **Try the sample**: Load "Social Network Timeline.json" from the samples to see the feature in action. + +## Technical Details + +### File Changes + +**SDK Package (`packages/sdk/`):** +- `src/filters/types.ts`: Added `TimelineFilterType` interface + +**Gephi-Lite Package (`packages/gephi-lite/`):** +- `src/core/filters/types.ts`: Exported `TimelineFilterType` +- `src/core/filters/utils.ts`: Added timeline filtering logic in `filterValue()` +- `src/components/GraphFilters/TimelineFilter.tsx`: New timeline slider component +- `src/components/GraphFilters/index.tsx`: Integrated timeline filter +- `src/components/modals/SelectFilterModal.tsx`: Auto-select timeline filter for edge date fields +- `src/locales/en.json`: Added timeline filter translations +- `public/samples/Social Network Timeline.json`: Sample graph with temporal data + +### Data Format + +Edges must have a date attribute defined in the `edgeFields` with: +```json +{ + "id": "timestamp", + "itemType": "edges", + "type": "date", + "format": "yyyy-MM-dd" // or any Luxon-compatible format +} +``` + +Edge data should include the timestamp: +```json +"edgeData": { + "e1": { + "timestamp": "2023-01-15", + "weight": 5 + } +} +``` + +## Future Enhancements (Optional) + +The following features could be added in future iterations: + +- **Animation Controls**: Play/pause buttons to automatically advance through time +- **Playback Speed**: Adjustable speed for timeline animation +- **Step Controls**: Previous/next buttons to move through time in discrete steps +- **Time Granularity**: Options to group by day, month, quarter, year +- **Range Presets**: Quick select buttons for common ranges (last month, last year, etc.) +- **Timeline Visualization**: More sophisticated timeline visualization options + +## Testing + +To test the feature: + +1. Start the development server: `npm start` +2. Navigate to http://localhost:5173/gephi-lite/ +3. Load the "Social Network Timeline" sample graph +4. Add a timeline filter on the "timestamp" edge field +5. Adjust the slider to see edges appear/disappear based on the selected time range + +## Notes + +- Timeline filters only work on edges (not nodes) +- The date format must be specified in the field definition +- Missing or invalid dates can be optionally kept using the "Keep missing values" option +- The filter uses Luxon's DateTime internally for robust date handling diff --git a/package-lock.json b/package-lock.json index 9345f4d7..bcc8a41b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1731,6 +1731,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -1748,6 +1749,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1765,6 +1767,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1782,6 +1785,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1799,6 +1803,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1816,6 +1821,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1833,6 +1839,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1850,6 +1857,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1867,6 +1875,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1884,6 +1893,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1901,6 +1911,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1918,6 +1929,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1935,6 +1947,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1952,6 +1965,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1969,6 +1983,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1986,6 +2001,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2018,6 +2034,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2035,6 +2052,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2052,6 +2070,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2069,6 +2088,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -2086,6 +2106,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -2103,6 +2124,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -2120,6 +2142,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2137,6 +2160,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2154,6 +2178,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -4901,6 +4926,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4916,6 +4942,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -5301,12 +5328,10 @@ }, "node_modules/@types/prop-types": { "version": "15.7.14", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.18", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -8456,7 +8481,6 @@ }, "node_modules/encoding": { "version": "0.1.13", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8465,7 +8489,6 @@ }, "node_modules/encoding/node_modules/iconv-lite": { "version": "0.6.3", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -11242,7 +11265,6 @@ }, "node_modules/graphology-types": { "version": "0.24.8", - "dev": true, "license": "MIT" }, "node_modules/graphology-utils": { @@ -17354,7 +17376,7 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/scheduler": { @@ -18463,6 +18485,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -18625,7 +18648,7 @@ }, "node_modules/typescript": { "version": "5.7.3", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/packages/gephi-lite/public/samples/Social Network Timeline.json b/packages/gephi-lite/public/samples/Social Network Timeline.json new file mode 100644 index 00000000..0135de68 --- /dev/null +++ b/packages/gephi-lite/public/samples/Social Network Timeline.json @@ -0,0 +1,229 @@ +{ + "type": "gephi-lite", + "version": "1.0.0", + "graphDataset": { + "nodeData": { + "alice": { + "label": "Alice", + "size": 10, + "color": "#e74c3c" + }, + "bob": { + "label": "Bob", + "size": 10, + "color": "#3498db" + }, + "carol": { + "label": "Carol", + "size": 10, + "color": "#2ecc71" + }, + "dave": { + "label": "Dave", + "size": 10, + "color": "#f39c12" + }, + "eve": { + "label": "Eve", + "size": 10, + "color": "#9b59b6" + } + }, + "edgeData": { + "e1": { + "timestamp": "2023-01-15", + "weight": 5, + "interaction": "message" + }, + "e2": { + "timestamp": "2023-02-20", + "weight": 3, + "interaction": "call" + }, + "e3": { + "timestamp": "2023-03-10", + "weight": 8, + "interaction": "meeting" + }, + "e4": { + "timestamp": "2023-04-05", + "weight": 2, + "interaction": "email" + }, + "e5": { + "timestamp": "2023-05-12", + "weight": 6, + "interaction": "message" + }, + "e6": { + "timestamp": "2023-06-18", + "weight": 4, + "interaction": "call" + }, + "e7": { + "timestamp": "2023-07-22", + "weight": 7, + "interaction": "meeting" + }, + "e8": { + "timestamp": "2023-08-30", + "weight": 3, + "interaction": "email" + }, + "e9": { + "timestamp": "2023-09-14", + "weight": 9, + "interaction": "message" + }, + "e10": { + "timestamp": "2023-10-25", + "weight": 5, + "interaction": "call" + } + }, + "layout": { + "alice": { "x": 0, "y": 100 }, + "bob": { "x": 100, "y": 0 }, + "carol": { "x": 200, "y": 100 }, + "dave": { "x": 100, "y": 200 }, + "eve": { "x": 100, "y": 100 } + }, + "metadata": { + "title": "Social Network Timeline" + }, + "nodeFields": [ + { + "id": "label", + "itemType": "nodes", + "type": "text" + }, + { + "id": "size", + "itemType": "nodes", + "type": "number" + }, + { + "id": "color", + "itemType": "nodes", + "type": "color" + } + ], + "edgeFields": [ + { + "id": "timestamp", + "itemType": "edges", + "type": "date", + "format": "yyyy-MM-dd" + }, + { + "id": "weight", + "itemType": "edges", + "type": "number" + }, + { + "id": "interaction", + "itemType": "edges", + "type": "category" + } + ], + "fullGraph": { + "options": { + "type": "undirected", + "multi": true, + "allowSelfLoops": true + }, + "attributes": {}, + "nodes": [ + { "key": "alice" }, + { "key": "bob" }, + { "key": "carol" }, + { "key": "dave" }, + { "key": "eve" } + ], + "edges": [ + { "key": "e1", "source": "alice", "target": "bob" }, + { "key": "e2", "source": "bob", "target": "carol" }, + { "key": "e3", "source": "carol", "target": "dave" }, + { "key": "e4", "source": "dave", "target": "eve" }, + { "key": "e5", "source": "eve", "target": "alice" }, + { "key": "e6", "source": "alice", "target": "carol" }, + { "key": "e7", "source": "bob", "target": "dave" }, + { "key": "e8", "source": "carol", "target": "eve" }, + { "key": "e9", "source": "alice", "target": "dave" }, + { "key": "e10", "source": "bob", "target": "eve" } + ] + } + }, + "filters": { + "filters": [] + }, + "appearance": { + "showEdges": { + "value": true + }, + "nodesSize": { + "type": "fixed", + "value": 10 + }, + "edgesSize": { + "type": "ranking", + "field": { + "id": "weight", + "itemType": "edges", + "type": "number" + }, + "missingSize": 2, + "minSize": 2, + "maxSize": 10 + }, + "backgroundColor": "#FFFFFF", + "nodesColor": { + "type": "fixed", + "value": "#666666" + }, + "edgesColor": { + "type": "fixed", + "value": "#cccccc" + }, + "nodesLabel": { + "type": "field", + "field": { + "id": "label", + "itemType": "nodes", + "type": "text" + }, + "missingValue": null + }, + "edgesLabel": { + "type": "none" + }, + "nodesLabelSize": { + "type": "fixed", + "value": 14, + "zoomCorrelation": 0, + "density": 1 + }, + "edgesLabelSize": { + "type": "fixed", + "value": 14, + "zoomCorrelation": 0, + "density": 1 + }, + "nodesLabelEllipsis": { + "type": "ellipsis", + "enabled": false, + "maxLength": 25 + }, + "edgesLabelEllipsis": { + "type": "ellipsis", + "enabled": false, + "maxLength": 25 + }, + "nodesImage": { + "type": "none" + }, + "edgesZIndex": { + "type": "none" + } + } +} diff --git a/packages/gephi-lite/src/components/GraphFilters/TimelineFilter.tsx b/packages/gephi-lite/src/components/GraphFilters/TimelineFilter.tsx new file mode 100644 index 00000000..ec7be420 --- /dev/null +++ b/packages/gephi-lite/src/components/GraphFilters/TimelineFilter.tsx @@ -0,0 +1,221 @@ +import cx from "classnames"; +import { clamp, flatMap, keyBy, last, mapValues, max, min, uniq } from "lodash"; +import { DateTime } from "luxon"; +import Slider, { SliderProps } from "rc-slider"; +import { FC, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useFiltersActions, useGraphDataset } from "../../core/context/dataContexts"; +import { TimelineFilterType } from "../../core/filters/types"; +import { inRangeIncluded } from "../../core/filters/utils"; +import { useFilteredGraphAt } from "../../core/graph"; +import { + computeAllDynamicAttributes, + getScalarFromStaticDynamicData, + mergeStaticDynamicData, +} from "../../core/graph/dynamicAttributes"; +import { castScalarToModelValue } from "../../core/graph/fieldModel"; +import { findRanges, shortenNumber } from "./utils"; + +interface TimeRange { + min: number; + max: number; + values: number[]; +} + +interface TimelineMetric { + unit: number; + step: number; + min: number; + max: number; + maxCount: number; + ranges: TimeRange[]; +} + +const TIMELINE_STYLE = { + dotStyle: { borderColor: "#ccc" }, + railStyle: { backgroundColor: "#ccc" }, + activeDotStyle: { borderColor: "black" }, + trackStyle: [{ backgroundColor: "black" }, { backgroundColor: "black" }], +}; + +export const TimelineFilter: FC<{ filter: TimelineFilterType; filterIndex: number }> = ({ filter, filterIndex }) => { + const parentGraph = useFilteredGraphAt(filterIndex - 1); + + const { edgeData } = useGraphDataset(); + + const { t } = useTranslation(); + const { updateFilter } = useFiltersActions(); + + const [timelineMetric, setTimelineMetric] = useState(); + + useEffect(() => { + const itemData = mergeStaticDynamicData( + edgeData, + filter.field.dynamic ? computeAllDynamicAttributes("edges", parentGraph) : {}, + ); + + const timestamps = flatMap(parentGraph.edges(), (edgeId) => { + const scalar = getScalarFromStaticDynamicData(itemData[edgeId], filter.field); + const value = castScalarToModelValue(scalar, filter.field); + if (value instanceof DateTime) { + return [value.toMillis()]; + } + return []; + }); + + const minTimestamp = min(timestamps); + const maxTimestamp = max(timestamps); + + if (minTimestamp !== undefined && maxTimestamp !== undefined) { + const { unit, ranges } = findRanges(minTimestamp, maxTimestamp); + const step = unit < 1 || unit >= 10 ? unit / 10 : 1; + const rangeValues = ranges.map((range) => { + const rangeTimestamps = timestamps.filter( + (t) => (!range[0] || range[0] <= t) && (!range[1] || t < range[1]), + ); + return { + min: range[0], + max: range[1], + values: rangeTimestamps, + }; + }); + + setTimelineMetric({ + min: ranges[0][0], + max: (last(ranges) || ranges[0])[1], + step, + unit, + ranges: rangeValues, + maxCount: Math.max(...rangeValues.map((r) => r.values.length)), + }); + } + }, [filter.field, parentGraph, edgeData]); + + const marks: SliderProps["marks"] = timelineMetric + ? mapValues( + keyBy( + uniq( + timelineMetric.ranges + .flatMap((r) => [r.min, r.max]) + .concat([ + filter.minDate || timelineMetric.min, + filter.maxDate !== undefined ? filter.maxDate + timelineMetric.step : timelineMetric.max, + ]), + ), + ), + () => "", + ) + : {}; + + const formatTimestamp = (timestamp: number) => { + return DateTime.fromMillis(timestamp).toFormat("yyyy-MM-dd"); + }; + + if (!timelineMetric) return null; + + const filteredGraph = useFilteredGraphAt(filterIndex); + const filteredEdgeData = mergeStaticDynamicData( + edgeData, + filter.field.dynamic ? computeAllDynamicAttributes("edges", filteredGraph) : {}, + ); + + return ( + <> +
+ + +
    + {timelineMetric.ranges.map((range, i) => { + const globalCount = range.values.length; + const filteredTimestamps = filteredGraph.edges().filter((edgeId) => { + const scalar = getScalarFromStaticDynamicData(filteredEdgeData[edgeId], filter.field); + const value = castScalarToModelValue(scalar, filter.field); + if (value instanceof DateTime) { + const t = value.toMillis(); + return range.min <= t && t < range.max; + } + return false; + }); + const filteredCount = filteredTimestamps.length; + + const globalHeight = (globalCount / timelineMetric.maxCount) * 100; + const filteredHeight = (filteredCount / timelineMetric.maxCount) * 100; + const isLabelInside = filteredHeight > 40; + return ( +
  • +
    +
    + {filteredCount !== 0 && ( + + {shortenNumber(filteredCount, globalCount)} + + )} +
    +
    +
  • + ); + })} +
+ + clamp(n, timelineMetric.min, timelineMetric.max + timelineMetric.step))} + {...timelineMetric} + marks={marks} + onChange={(value) => { + if (Array.isArray(value)) { + const [minSelected, maxSelected] = value; + const newMin = minSelected; + const newMax = maxSelected; + + updateFilter(filterIndex, { + ...filter, + minDate: newMin === timelineMetric.min ? undefined : newMin, + maxDate: newMax - timelineMetric.step === timelineMetric.max ? undefined : newMax - timelineMetric.step, + }); + } + }} + {...TIMELINE_STYLE} + /> + +
+
+ + +
+
+ + +
+
+
+ + ); +}; diff --git a/packages/gephi-lite/src/components/GraphFilters/index.tsx b/packages/gephi-lite/src/components/GraphFilters/index.tsx index e627d4bb..5d89281d 100644 --- a/packages/gephi-lite/src/components/GraphFilters/index.tsx +++ b/packages/gephi-lite/src/components/GraphFilters/index.tsx @@ -20,6 +20,7 @@ import { MissingValueFilter } from "./MissingValueFilter"; import { RangeFilter } from "./RangeFilter"; import { ScriptFilter } from "./ScriptFilter"; import { TermsFilter } from "./TermsFilter"; +import { TimelineFilter } from "./TimelineFilter"; import { TopologicalFilter } from "./TopologicalFilter"; const FilterInStack: FC<{ @@ -47,7 +48,8 @@ const FilterInStack: FC<{ ) : ( )} - {(filter.type === "range" || filter.type === "terms") && staticDynamicAttributeLabel(filter.field)} + {(filter.type === "range" || filter.type === "terms" || filter.type === "timeline") && + staticDynamicAttributeLabel(filter.field)} {filter.type === "topological" && t(`filters.topology.${filter.topologicalFilterId}.label`)} {filter.type === "script" && t("filters.script")} @@ -61,6 +63,7 @@ const FilterInStack: FC<{ {!filter.disabled && (
{filter.type === "range" && } + {filter.type === "timeline" && } {filter.type === "terms" && } {filter.type === "script" && } {filter.type === "topological" && } diff --git a/packages/gephi-lite/src/components/modals/SelectFilterModal.tsx b/packages/gephi-lite/src/components/modals/SelectFilterModal.tsx index 8a6edc88..5aa406a3 100644 --- a/packages/gephi-lite/src/components/modals/SelectFilterModal.tsx +++ b/packages/gephi-lite/src/components/modals/SelectFilterModal.tsx @@ -14,8 +14,8 @@ import { ModalProps } from "../../core/modals/types"; import { FieldModelIcons, ItemTypeIcon, MissingValueFilterIcon } from "../common-icons"; import { Modal } from "../modals"; -const FILTER_TYPES_PER_FIELD_TYPES: Record = { - date: "range", +const FILTER_TYPES_PER_FIELD_TYPES: Record = { + date: "range", // Will be overridden to "timeline" for edges number: "range", keywords: "terms", category: "terms", @@ -91,7 +91,11 @@ const SelectFilterModal: FC< [ fieldsList.map((field) => { const Icon = FieldModelIcons[field.type]; - const filterType = FILTER_TYPES_PER_FIELD_TYPES[field.type]; + // Use timeline filter for date fields on edges, range filter for nodes + let filterType = FILTER_TYPES_PER_FIELD_TYPES[field.type]; + if (filterType === "range" && field.type === "date" && type === "edges") { + filterType = "timeline"; + } return (