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/TimeRangeSlider.tsx b/packages/gephi-lite/src/components/GraphFilters/TimeRangeSlider.tsx new file mode 100644 index 00000000..ec9a1bee --- /dev/null +++ b/packages/gephi-lite/src/components/GraphFilters/TimeRangeSlider.tsx @@ -0,0 +1,389 @@ +import { DateTime } from "luxon"; +import { FC, useCallback, useEffect, useRef, useState } from "react"; + +interface TimeRangeSliderProps { + min: Date; + max: Date; + value: [Date, Date]; + onChange: (range: [Date, Date]) => void; + onCommit?: (range: [Date, Date]) => void; + step?: number; // milliseconds, defaults to 1 day + disabled?: boolean; + formatLabel?: (date: Date) => string; + marks?: Record; +} + +type DragMode = "min" | "max" | "range" | null; + +export const TimeRangeSlider: FC = ({ + min, + max, + value, + onChange, + onCommit, + step = 24 * 60 * 60 * 1000, // 1 day default + disabled = false, + formatLabel, + marks, +}) => { + const railRef = useRef(null); + const [dragMode, setDragMode] = useState(null); + const [dragStartX, setDragStartX] = useState(0); + const [dragStartValues, setDragStartValues] = useState<[number, number]>([0, 0]); + const [focusedThumb, setFocusedThumb] = useState<"min" | "max" | null>(null); + + const minTime = min.getTime(); + const maxTime = max.getTime(); + const totalDuration = maxTime - minTime; + + const [minValue, maxValue] = value; + const minValueTime = minValue.getTime(); + const maxValueTime = maxValue.getTime(); + + // Calculate positions as percentages + const minPosition = ((minValueTime - minTime) / totalDuration) * 100; + const maxPosition = ((maxValueTime - minTime) / totalDuration) * 100; + + const formatDate = useCallback( + (date: Date) => { + if (formatLabel) return formatLabel(date); + return DateTime.fromJSDate(date).toFormat("yyyy-MM-dd"); + }, + [formatLabel], + ); + + const snapToStep = useCallback( + (time: number) => { + const stepsFromMin = Math.round((time - minTime) / step); + return Math.max(minTime, Math.min(maxTime, minTime + stepsFromMin * step)); + }, + [minTime, maxTime, step], + ); + + const getTimeFromPosition = useCallback( + (clientX: number) => { + if (!railRef.current) return minTime; + const rect = railRef.current.getBoundingClientRect(); + const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const time = minTime + percentage * totalDuration; + return snapToStep(time); + }, + [minTime, totalDuration, snapToStep], + ); + + const handleMouseDown = useCallback( + (mode: DragMode, e: React.MouseEvent) => { + if (disabled) return; + e.preventDefault(); + setDragMode(mode); + setDragStartX(e.clientX); + setDragStartValues([minValueTime, maxValueTime]); + }, + [disabled, minValueTime, maxValueTime], + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!dragMode || !railRef.current) return; + + const rect = railRef.current.getBoundingClientRect(); + const deltaX = e.clientX - dragStartX; + const deltaTime = (deltaX / rect.width) * totalDuration; + const [startMin, startMax] = dragStartValues; + + let newMinTime: number; + let newMaxTime: number; + + if (dragMode === "min") { + newMinTime = snapToStep(startMin + deltaTime); + newMinTime = Math.max(minTime, Math.min(newMinTime, startMax - step)); + newMaxTime = maxValueTime; + } else if (dragMode === "max") { + newMaxTime = snapToStep(startMax + deltaTime); + newMaxTime = Math.max(startMin + step, Math.min(newMaxTime, maxTime)); + newMinTime = minValueTime; + } else if (dragMode === "range") { + const rangeDuration = startMax - startMin; + newMinTime = snapToStep(startMin + deltaTime); + newMaxTime = newMinTime + rangeDuration; + + // Keep range within bounds + if (newMinTime < minTime) { + newMinTime = minTime; + newMaxTime = minTime + rangeDuration; + } + if (newMaxTime > maxTime) { + newMaxTime = maxTime; + newMinTime = maxTime - rangeDuration; + } + } else { + return; + } + + onChange([new Date(newMinTime), new Date(newMaxTime)]); + }, + [ + dragMode, + dragStartX, + dragStartValues, + totalDuration, + snapToStep, + minTime, + maxTime, + step, + onChange, + minValueTime, + maxValueTime, + ], + ); + + const handleMouseUp = useCallback(() => { + if (dragMode && onCommit) { + onCommit([minValue, maxValue]); + } + setDragMode(null); + }, [dragMode, minValue, maxValue, onCommit]); + + useEffect(() => { + if (dragMode) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [dragMode, handleMouseMove, handleMouseUp]); + + const handleKeyDown = useCallback( + (thumb: "min" | "max", e: React.KeyboardEvent) => { + if (disabled) return; + + const isShiftPressed = e.shiftKey; + let handled = false; + + if (isShiftPressed) { + // Shift + Arrow: Pan the range + const rangeDuration = maxValueTime - minValueTime; + let newMinTime = minValueTime; + let newMaxTime = maxValueTime; + + if (e.key === "ArrowRight") { + newMinTime = Math.min(minValueTime + step, maxTime - rangeDuration); + newMaxTime = newMinTime + rangeDuration; + handled = true; + } else if (e.key === "ArrowLeft") { + newMinTime = Math.max(minValueTime - step, minTime); + newMaxTime = newMinTime + rangeDuration; + handled = true; + } + + if (handled) { + onChange([new Date(newMinTime), new Date(newMaxTime)]); + if (onCommit) onCommit([new Date(newMinTime), new Date(newMaxTime)]); + } + } else { + // Arrow: Resize the range + if (thumb === "min") { + if (e.key === "ArrowRight") { + const newMinTime = Math.min(minValueTime + step, maxValueTime - step); + onChange([new Date(newMinTime), maxValue]); + if (onCommit) onCommit([new Date(newMinTime), maxValue]); + handled = true; + } else if (e.key === "ArrowLeft") { + const newMinTime = Math.max(minValueTime - step, minTime); + onChange([new Date(newMinTime), maxValue]); + if (onCommit) onCommit([new Date(newMinTime), maxValue]); + handled = true; + } + } else { + if (e.key === "ArrowRight") { + const newMaxTime = Math.min(maxValueTime + step, maxTime); + onChange([minValue, new Date(newMaxTime)]); + if (onCommit) onCommit([minValue, new Date(newMaxTime)]); + handled = true; + } else if (e.key === "ArrowLeft") { + const newMaxTime = Math.max(maxValueTime - step, minValueTime + step); + onChange([minValue, new Date(newMaxTime)]); + if (onCommit) onCommit([minValue, new Date(newMaxTime)]); + handled = true; + } + } + } + + if (handled) { + e.preventDefault(); + } + }, + [disabled, minValueTime, maxValueTime, step, minTime, maxTime, minValue, maxValue, onChange, onCommit], + ); + + const handleRailClick = useCallback( + (e: React.MouseEvent) => { + if (disabled || dragMode) return; + const clickedTime = getTimeFromPosition(e.clientX); + const rangeCenter = (minValueTime + maxValueTime) / 2; + + if (clickedTime < rangeCenter) { + // Click before range: move min thumb + const newMinTime = Math.max(minTime, Math.min(clickedTime, maxValueTime - step)); + onChange([new Date(newMinTime), maxValue]); + if (onCommit) onCommit([new Date(newMinTime), maxValue]); + } else { + // Click after range: move max thumb + const newMaxTime = Math.max(minValueTime + step, Math.min(clickedTime, maxTime)); + onChange([minValue, new Date(newMaxTime)]); + if (onCommit) onCommit([minValue, new Date(newMaxTime)]); + } + }, + [ + disabled, + dragMode, + getTimeFromPosition, + minValueTime, + maxValueTime, + minTime, + maxTime, + step, + minValue, + maxValue, + onChange, + onCommit, + ], + ); + + return ( +
+ {/* Rail */} +
+ {/* Track (selected range) */} +
handleMouseDown("range", e)} + style={{ + position: "absolute", + left: `${minPosition}%`, + right: `${100 - maxPosition}%`, + height: "100%", + backgroundColor: disabled ? "#ccc" : "#000", + borderRadius: "3px", + cursor: disabled ? "not-allowed" : dragMode === "range" ? "grabbing" : "grab", + }} + role="presentation" + /> + + {/* Min Thumb */} +
handleMouseDown("min", e)} + onKeyDown={(e) => handleKeyDown("min", e)} + onFocus={() => setFocusedThumb("min")} + onBlur={() => setFocusedThumb(null)} + tabIndex={disabled ? -1 : 0} + role="slider" + aria-valuemin={minTime} + aria-valuemax={maxTime} + aria-valuenow={minValueTime} + aria-valuetext={formatDate(minValue)} + aria-label="Minimum date" + aria-disabled={disabled} + style={{ + position: "absolute", + left: `${minPosition}%`, + top: "50%", + transform: "translate(-50%, -50%)", + width: "20px", + height: "20px", + backgroundColor: disabled ? "#999" : "#000", + borderRadius: "50%", + border: focusedThumb === "min" ? "2px solid #0066cc" : "2px solid #fff", + boxShadow: "0 2px 4px rgba(0,0,0,0.2)", + cursor: disabled ? "not-allowed" : dragMode === "min" ? "grabbing" : "grab", + zIndex: 2, + }} + /> + + {/* Max Thumb */} +
handleMouseDown("max", e)} + onKeyDown={(e) => handleKeyDown("max", e)} + onFocus={() => setFocusedThumb("max")} + onBlur={() => setFocusedThumb(null)} + tabIndex={disabled ? -1 : 0} + role="slider" + aria-valuemin={minTime} + aria-valuemax={maxTime} + aria-valuenow={maxValueTime} + aria-valuetext={formatDate(maxValue)} + aria-label="Maximum date" + aria-disabled={disabled} + style={{ + position: "absolute", + left: `${maxPosition}%`, + top: "50%", + transform: "translate(-50%, -50%)", + width: "20px", + height: "20px", + backgroundColor: disabled ? "#999" : "#000", + borderRadius: "50%", + border: focusedThumb === "max" ? "2px solid #0066cc" : "2px solid #fff", + boxShadow: "0 2px 4px rgba(0,0,0,0.2)", + cursor: disabled ? "not-allowed" : dragMode === "max" ? "grabbing" : "grab", + zIndex: 2, + }} + /> + + {/* Tick marks */} + {marks && + Object.entries(marks).map(([time, label]) => { + const timeNum = Number(time); + const position = ((timeNum - minTime) / totalDuration) * 100; + return ( +
+ {label} +
+ ); + })} +
+ + {/* Value labels */} +
+
+ From: {formatDate(minValue)} +
+
+ To: {formatDate(maxValue)} +
+
+ + {/* Instructions */} + {focusedThumb && ( +
+ Arrow keys: resize range • Shift + Arrow keys: pan range +
+ )} +
+ ); +}; 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..d7b92fb7 --- /dev/null +++ b/packages/gephi-lite/src/components/GraphFilters/TimelineFilter.tsx @@ -0,0 +1,185 @@ +import cx from "classnames"; +import { flatMap, last, max, min } from "lodash"; +import { DateTime } from "luxon"; +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 { useFilteredGraphAt } from "../../core/graph"; +import { + computeAllDynamicAttributes, + getScalarFromStaticDynamicData, + mergeStaticDynamicData, +} from "../../core/graph/dynamicAttributes"; +import { castScalarToModelValue } from "../../core/graph/fieldModel"; +import { TimeRangeSlider } from "./TimeRangeSlider"; +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[]; +} + +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]); + + // Call hooks before any early returns + const filteredGraph = useFilteredGraphAt(filterIndex); + + if (!timelineMetric) return null; + + 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)} + + )} +
    +
    +
  • + ); + })} +
+ + { + const [minDate, maxDate] = range; + const newMin = minDate.getTime(); + const newMax = maxDate.getTime(); + + updateFilter(filterIndex, { + ...filter, + minDate: newMin === timelineMetric.min ? undefined : newMin, + maxDate: newMax === timelineMetric.max ? undefined : newMax, + }); + }} + step={timelineMetric.step} + disabled={timelineMetric.min === timelineMetric.max} + /> + +
+ { + updateFilter(filterIndex, { + ...filter, + fadeInsteadOfHide: e.target.checked, + }); + }} + disabled={timelineMetric.min === timelineMetric.max} + /> + +
+
+ + ); +}; 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 36564eaa..e04f700b 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 (