@cratis/components 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/cjs/PivotViewer/PivotViewer.css +1258 -0
  2. package/dist/cjs/PivotViewer/components/Spinner.css +77 -0
  3. package/dist/cjs/TimeMachine/EventsView.css +213 -0
  4. package/dist/cjs/TimeMachine/TimeMachine.css +567 -0
  5. package/dist/esm/PivotViewer/PivotViewer.css +1258 -0
  6. package/dist/esm/PivotViewer/components/Spinner.css +77 -0
  7. package/dist/esm/TimeMachine/EventsView.css +213 -0
  8. package/dist/esm/TimeMachine/TimeMachine.css +567 -0
  9. package/package.json +3 -4
  10. package/.storybook/main.ts +0 -24
  11. package/CommandDialog/CommandDialog.stories.tsx +0 -25
  12. package/CommandDialog/CommandDialog.tsx +0 -161
  13. package/CommandDialog/index.ts +0 -4
  14. package/CommandForm/CommandForm.stories.tsx +0 -24
  15. package/CommandForm/CommandForm.tsx +0 -266
  16. package/CommandForm/CommandFormField.tsx +0 -27
  17. package/CommandForm/CommandFormFields.tsx +0 -142
  18. package/CommandForm/DatePickerField.tsx +0 -57
  19. package/CommandForm/DropdownField.tsx +0 -65
  20. package/CommandForm/InputTextField.tsx +0 -62
  21. package/CommandForm/SliderField.tsx +0 -68
  22. package/CommandForm/index.ts +0 -10
  23. package/Common/ErrorBoundary.stories.tsx +0 -10
  24. package/Common/ErrorBoundary.tsx +0 -41
  25. package/Common/FormElement.stories.tsx +0 -10
  26. package/Common/FormElement.tsx +0 -20
  27. package/Common/Page.stories.tsx +0 -10
  28. package/Common/Page.tsx +0 -21
  29. package/Common/index.ts +0 -6
  30. package/DataPage/DataPage.stories.tsx +0 -10
  31. package/DataPage/DataPage.tsx +0 -191
  32. package/DataPage/index.ts +0 -4
  33. package/DataTables/DataTableForObservableQuery.stories.tsx +0 -10
  34. package/DataTables/DataTableForObservableQuery.tsx +0 -97
  35. package/DataTables/DataTableForQuery.stories.tsx +0 -10
  36. package/DataTables/DataTableForQuery.tsx +0 -97
  37. package/DataTables/index.ts +0 -5
  38. package/Dialogs/BusyIndicatorDialog.stories.tsx +0 -26
  39. package/Dialogs/BusyIndicatorDialog.tsx +0 -26
  40. package/Dialogs/ConfirmationDialog.stories.tsx +0 -36
  41. package/Dialogs/ConfirmationDialog.tsx +0 -75
  42. package/Dialogs/index.ts +0 -5
  43. package/Dropdown/Dropdown.tsx +0 -23
  44. package/Dropdown/index.ts +0 -4
  45. package/PivotViewer/PivotViewer.stories.tsx +0 -24
  46. package/PivotViewer/PivotViewer.tsx +0 -791
  47. package/PivotViewer/components/AxisLabels.tsx +0 -69
  48. package/PivotViewer/components/DetailPanel.tsx +0 -108
  49. package/PivotViewer/components/FilterPanel.tsx +0 -189
  50. package/PivotViewer/components/FilterPanelContainer.tsx +0 -10
  51. package/PivotViewer/components/PivotCanvas.tsx +0 -660
  52. package/PivotViewer/components/PivotViewerMain.tsx +0 -229
  53. package/PivotViewer/components/RangeHistogramFilter.tsx +0 -220
  54. package/PivotViewer/components/Spinner.tsx +0 -21
  55. package/PivotViewer/components/Toolbar.tsx +0 -130
  56. package/PivotViewer/components/ToolbarContainer.tsx +0 -10
  57. package/PivotViewer/components/index.ts +0 -12
  58. package/PivotViewer/components/pivot/animation.ts +0 -108
  59. package/PivotViewer/components/pivot/buckets.ts +0 -152
  60. package/PivotViewer/components/pivot/colorResolver.ts +0 -67
  61. package/PivotViewer/components/pivot/constants.ts +0 -46
  62. package/PivotViewer/components/pivot/sprites.ts +0 -265
  63. package/PivotViewer/components/pivot/visibility.ts +0 -319
  64. package/PivotViewer/constants.ts +0 -9
  65. package/PivotViewer/engine/layout.ts +0 -149
  66. package/PivotViewer/engine/pivot.worker.ts +0 -86
  67. package/PivotViewer/engine/store.ts +0 -437
  68. package/PivotViewer/engine/types.ts +0 -255
  69. package/PivotViewer/hooks/index.ts +0 -13
  70. package/PivotViewer/hooks/useContainerDimensions.ts +0 -45
  71. package/PivotViewer/hooks/useDimensionState.ts +0 -53
  72. package/PivotViewer/hooks/useFilterOptions.ts +0 -36
  73. package/PivotViewer/hooks/useFilterPanelDrag.ts +0 -49
  74. package/PivotViewer/hooks/useFilterState.ts +0 -106
  75. package/PivotViewer/hooks/useFilteredData.ts +0 -119
  76. package/PivotViewer/hooks/usePanning.ts +0 -163
  77. package/PivotViewer/hooks/usePivotEngine.ts +0 -252
  78. package/PivotViewer/hooks/useSelectedItem.ts +0 -402
  79. package/PivotViewer/hooks/useWheelZoom.ts +0 -114
  80. package/PivotViewer/hooks/useZoomState.ts +0 -34
  81. package/PivotViewer/index.ts +0 -7
  82. package/PivotViewer/types.ts +0 -59
  83. package/PivotViewer/utils/animations.ts +0 -249
  84. package/PivotViewer/utils/constants.ts +0 -20
  85. package/PivotViewer/utils/index.ts +0 -6
  86. package/PivotViewer/utils/selection.ts +0 -292
  87. package/PivotViewer/utils/utils.ts +0 -259
  88. package/TimeMachine/EventsView.stories.tsx +0 -10
  89. package/TimeMachine/EventsView.tsx +0 -119
  90. package/TimeMachine/Properties.stories.tsx +0 -10
  91. package/TimeMachine/Properties.tsx +0 -98
  92. package/TimeMachine/ReadModelView.stories.tsx +0 -10
  93. package/TimeMachine/ReadModelView.tsx +0 -143
  94. package/TimeMachine/TimeMachine.stories.tsx +0 -10
  95. package/TimeMachine/TimeMachine.tsx +0 -244
  96. package/TimeMachine/index.ts +0 -8
  97. package/TimeMachine/types.ts +0 -23
  98. package/global.d.ts +0 -11
  99. package/index.ts +0 -22
  100. package/useOverlayZIndex.ts +0 -32
  101. package/vite.config.ts +0 -80
@@ -1,45 +0,0 @@
1
- // Copyright (c) Cratis. All rights reserved.
2
- // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
-
4
- import { useEffect, useState } from 'react';
5
-
6
- /**
7
- * Hook to track container dimensions using ResizeObserver.
8
- *
9
- * @param containerRef - Ref to the container element.
10
- * @param dependency - Optional dependency to trigger updates.
11
- * @returns Dimensions of the container.
12
- */
13
- export function useContainerDimensions(
14
- containerRef: React.RefObject<HTMLDivElement>,
15
- dependency?: unknown
16
- ) {
17
- const [dimensions, setDimensions] = useState({ width: 1200, height: 600 });
18
-
19
- useEffect(() => {
20
- const container = containerRef.current;
21
- if (!container) return;
22
-
23
- const updateDimensions = () => {
24
- setDimensions({
25
- width: container.clientWidth,
26
- height: container.clientHeight,
27
- });
28
- };
29
-
30
- // Initial dimensions
31
- updateDimensions();
32
-
33
- // Observe for resize changes
34
- const resizeObserver = new ResizeObserver(() => {
35
- updateDimensions();
36
- });
37
- resizeObserver.observe(container);
38
-
39
- return () => {
40
- resizeObserver.disconnect();
41
- };
42
- }, [containerRef, dependency]);
43
-
44
- return dimensions;
45
- }
@@ -1,53 +0,0 @@
1
- // Copyright (c) Cratis. All rights reserved.
2
- // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
-
4
- import { useMemo, useState, useEffect, useCallback } from 'react';
5
- import type { PivotDimension } from '../types';
6
-
7
- export type PivotDimensionFilter = string | null;
8
-
9
- export function useDimensionState<TItem extends object>(
10
- dimensions: PivotDimension<TItem>[],
11
- defaultDimensionKey?: string
12
- ) {
13
- const dimensionMap = useMemo(() => {
14
- const map = new Map<string, PivotDimension<TItem>>();
15
- dimensions.forEach((dimension) => map.set(dimension.key, dimension));
16
- return map;
17
- }, [dimensions]);
18
-
19
- const initialDimension = useMemo(() => {
20
- if (defaultDimensionKey && dimensionMap.has(defaultDimensionKey)) {
21
- return defaultDimensionKey;
22
- }
23
- return dimensions[0]?.key ?? '';
24
- }, [defaultDimensionKey, dimensionMap, dimensions]);
25
-
26
- const [activeDimensionKey, setActiveDimensionKey] = useState(initialDimension);
27
- const [dimensionFilter, setDimensionFilter] = useState<string | null>(null);
28
-
29
- useEffect(() => {
30
- setActiveDimensionKey((previous) => {
31
- if (previous && dimensionMap.has(previous)) {
32
- return previous;
33
- }
34
- return initialDimension;
35
- });
36
- setDimensionFilter(null);
37
- }, [dimensionMap, initialDimension]);
38
-
39
- const activeDimension = dimensionMap.get(activeDimensionKey) ?? dimensions[0];
40
-
41
- const handleAxisLabelClick = useCallback((groupKey: string) => {
42
- setDimensionFilter((prev) => (prev === groupKey ? null : groupKey));
43
- }, []);
44
-
45
- return {
46
- dimensionMap,
47
- activeDimensionKey,
48
- setActiveDimensionKey,
49
- activeDimension,
50
- dimensionFilter,
51
- handleAxisLabelClick,
52
- };
53
- }
@@ -1,36 +0,0 @@
1
- // Copyright (c) Cratis. All rights reserved.
2
- // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
-
4
- import { useMemo } from 'react';
5
- import type { PivotFilter, PivotFilterOption, PivotPrimitive } from '../types';
6
- import { applyFilters, computeFilterOptions, computeNumericRange } from '../utils/utils';
7
- import type { FilterState, RangeFilterState } from '../types';
8
-
9
- export function useFilterOptions<TItem extends object>(
10
- data: TItem[],
11
- filters: PivotFilter<TItem>[] | undefined,
12
- filterState: FilterState,
13
- rangeFilterState: RangeFilterState
14
- ) {
15
- return useMemo(() => {
16
- if (!filters?.length) {
17
- return [] as {
18
- filter: PivotFilter<TItem>;
19
- options: PivotFilterOption[];
20
- numericRange?: { min: number; max: number; values: PivotPrimitive[] };
21
- }[];
22
- }
23
-
24
- return filters.map((filter) => {
25
- const baseData = applyFilters(data, filters, filterState, rangeFilterState, filter.key);
26
-
27
- if (filter.type === 'number') {
28
- const numericRange = computeNumericRange(baseData, filter);
29
- return { filter, options: [], numericRange };
30
- }
31
-
32
- const options = computeFilterOptions(baseData, filter);
33
- return { filter, options };
34
- });
35
- }, [data, filters, filterState, rangeFilterState]);
36
- }
@@ -1,49 +0,0 @@
1
- // Copyright (c) Cratis. All rights reserved.
2
- // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
-
4
- import { useState, useCallback, useEffect } from 'react';
5
-
6
- export function useFilterPanelDrag() {
7
- const [filterPanelPos, setFilterPanelPos] = useState({ x: 16, y: 88 });
8
- const [isDraggingPanel, setIsDraggingPanel] = useState(false);
9
- const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
10
-
11
- const handleFilterPanelDragStart = useCallback((e: React.MouseEvent) => {
12
- (e as any).preventDefault?.();
13
- setIsDraggingPanel(true);
14
- setDragOffset({
15
- x: e.clientX - filterPanelPos.x,
16
- y: e.clientY - filterPanelPos.y,
17
- });
18
- }, [filterPanelPos]);
19
-
20
- const handleFilterPanelDrag = useCallback((e: MouseEvent) => {
21
- if (!isDraggingPanel) return;
22
- setFilterPanelPos({
23
- x: Math.max(0, e.clientX - dragOffset.x),
24
- y: Math.max(60, e.clientY - dragOffset.y),
25
- });
26
- }, [isDraggingPanel, dragOffset]);
27
-
28
- const handleFilterPanelDragEnd = useCallback(() => {
29
- setIsDraggingPanel(false);
30
- }, []);
31
-
32
- useEffect(() => {
33
- if (isDraggingPanel) {
34
- document.addEventListener('mousemove', handleFilterPanelDrag);
35
- document.addEventListener('mouseup', handleFilterPanelDragEnd);
36
- return () => {
37
- document.removeEventListener('mousemove', handleFilterPanelDrag);
38
- document.removeEventListener('mouseup', handleFilterPanelDragEnd);
39
- };
40
- }
41
- return undefined;
42
- }, [isDraggingPanel, handleFilterPanelDrag, handleFilterPanelDragEnd]);
43
-
44
- return {
45
- filterPanelPos,
46
- isDraggingPanel,
47
- handleFilterPanelDragStart,
48
- };
49
- }
@@ -1,106 +0,0 @@
1
- // Copyright (c) Cratis. All rights reserved.
2
- // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
-
4
- import { useState, useEffect, useCallback } from 'react';
5
- import { buildFilterState, buildRangeFilterState } from '../utils/utils';
6
- import { FilterState, RangeFilterState, PivotFilter } from '../types';
7
-
8
- /**
9
- * Hook to manage filter state.
10
- */
11
- export function useFilterState<TItem extends object>(
12
- filters: PivotFilter<TItem>[] | undefined
13
- ) {
14
- const [filterState, setFilterState] = useState<FilterState>(() => buildFilterState(filters));
15
- const [rangeFilterState, setRangeFilterState] = useState<RangeFilterState>(() => buildRangeFilterState(filters));
16
- const [expandedFilterKey, setExpandedFilterKey] = useState<string | null>(filters?.[0]?.key ?? null);
17
-
18
- useEffect(() => {
19
- setFilterState((prev) => {
20
- const next = buildFilterState(filters);
21
- if (!filters) return next;
22
-
23
- filters.forEach((filter) => {
24
- if (prev[filter.key]) {
25
- next[filter.key] = new Set(prev[filter.key]);
26
- }
27
- });
28
-
29
- return next;
30
- });
31
- setRangeFilterState((prev) => {
32
- const next = buildRangeFilterState(filters);
33
- if (!filters) return next;
34
- filters.forEach((filter) => {
35
- if (filter.type === 'number' && filter.key in prev) {
36
- next[filter.key] = prev[filter.key];
37
- }
38
- });
39
- return next;
40
- });
41
- }, [filters]);
42
-
43
- useEffect(() => {
44
- if (!filters?.length) {
45
- setExpandedFilterKey(null);
46
- return;
47
- }
48
-
49
- setExpandedFilterKey((current) => {
50
- if (current && filters.some((filter) => filter.key === current)) {
51
- return current;
52
- }
53
- return filters[0]?.key ?? null;
54
- });
55
- }, [filters]);
56
-
57
- const handleToggleFilter = useCallback((filterKey: string, optionKey: string, multi: boolean | undefined) => {
58
- setFilterState((prev) => {
59
- const next: FilterState = { ...prev };
60
- const current = new Set(prev[filterKey] ?? []);
61
-
62
- if (multi) {
63
- if (current.has(optionKey)) {
64
- current.delete(optionKey);
65
- } else {
66
- current.add(optionKey);
67
- }
68
- } else {
69
- if (current.has(optionKey)) {
70
- current.clear();
71
- } else {
72
- current.clear();
73
- current.add(optionKey);
74
- }
75
- }
76
-
77
- next[filterKey] = current;
78
- return next;
79
- });
80
- }, []);
81
-
82
- const handleClearFilter = useCallback((filterKey: string) => {
83
- setFilterState((prev) => {
84
- const next: FilterState = { ...prev };
85
- next[filterKey] = new Set();
86
- return next;
87
- });
88
- }, []);
89
-
90
- const handleRangeChange = useCallback((filterKey: string, range: [number, number] | null) => {
91
- setRangeFilterState((prev) => ({
92
- ...prev,
93
- [filterKey]: range,
94
- }));
95
- }, []);
96
-
97
- return {
98
- filterState,
99
- rangeFilterState,
100
- expandedFilterKey,
101
- setExpandedFilterKey,
102
- handleToggleFilter,
103
- handleClearFilter,
104
- handleRangeChange,
105
- };
106
- }
@@ -1,119 +0,0 @@
1
- // Copyright (c) Cratis. All rights reserved.
2
- // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
-
4
- import { useMemo } from 'react';
5
- import type { PivotDimension, PivotFilter, PivotGroup } from '../types';
6
- import { applyFilters, groupData, toKey } from '../utils/utils';
7
- import type { FilterState, RangeFilterState } from '../utils/utils';
8
-
9
- export function useFilteredData<TItem extends object>(
10
- data: TItem[],
11
- filters: PivotFilter<TItem>[] | undefined,
12
- filterState: FilterState,
13
- rangeFilterState: RangeFilterState,
14
- activeDimension: PivotDimension<TItem> | undefined,
15
- dimensionFilter: string | null,
16
- searchTerm: string,
17
- searchFields?: (keyof TItem)[]
18
- ) {
19
- const filteredData = useMemo(() => {
20
- let dataWithFilters = applyFilters(data, filters, filterState, rangeFilterState);
21
-
22
- if (dimensionFilter && activeDimension) {
23
- dataWithFilters = dataWithFilters.filter((item) => {
24
- const value = activeDimension.getValue(item);
25
- const key = toKey(value);
26
- return key === dimensionFilter;
27
- });
28
- }
29
-
30
- if (!searchTerm) {
31
- return dataWithFilters;
32
- }
33
-
34
- // Optimize search by using indexOf instead of includes for better performance
35
- return dataWithFilters.filter((item) => {
36
- if (searchFields && searchFields.length) {
37
- for (const field of searchFields) {
38
- const value = item[field];
39
- if (value !== undefined && String(value).toLowerCase().indexOf(searchTerm) !== -1) {
40
- return true;
41
- }
42
- }
43
- return false;
44
- }
45
-
46
- // Fallback to JSON stringify - less efficient but comprehensive
47
- return JSON.stringify(item).toLowerCase().indexOf(searchTerm) !== -1;
48
- });
49
- }, [data, filters, filterState, rangeFilterState, dimensionFilter, activeDimension, searchFields, searchTerm]);
50
-
51
- const allGroupsForBuckets = useMemo(() => {
52
- if (!activeDimension) {
53
- return [] as PivotGroup<TItem>[];
54
- }
55
- const dataWithoutDimensionFilter = applyFilters(data, filters, filterState, rangeFilterState);
56
- return groupData(dataWithoutDimensionFilter, activeDimension);
57
- }, [data, filters, filterState, rangeFilterState, activeDimension]);
58
-
59
- const groupedGroups = useMemo(() => {
60
- if (!activeDimension) {
61
- return [] as PivotGroup<TItem>[];
62
- }
63
-
64
- if (dimensionFilter) {
65
- const filteredGroup = allGroupsForBuckets.find((group) => group.key === dimensionFilter);
66
- return filteredGroup ? [filteredGroup] : [];
67
- }
68
-
69
- return groupData(filteredData, activeDimension);
70
- }, [filteredData, activeDimension, dimensionFilter, allGroupsForBuckets]);
71
-
72
- const collectionGroup = useMemo(() => {
73
- if (filteredData.length === 0) {
74
- return [] as PivotGroup<TItem>[];
75
- }
76
-
77
- // Sort by active dimension when in collection mode
78
- const sortedData = activeDimension
79
- ? [...filteredData].sort((a, b) => {
80
- const valueA = activeDimension.getValue(a);
81
- const valueB = activeDimension.getValue(b);
82
-
83
- // Handle null/undefined
84
- if (valueA == null && valueB == null) return 0;
85
- if (valueA == null) return 1;
86
- if (valueB == null) return -1;
87
-
88
- // Handle dates
89
- if (valueA instanceof Date && valueB instanceof Date) {
90
- return valueA.getTime() - valueB.getTime();
91
- }
92
-
93
- // Handle numbers
94
- if (typeof valueA === 'number' && typeof valueB === 'number') {
95
- return valueA - valueB;
96
- }
97
-
98
- // Handle strings
99
- return String(valueA).localeCompare(String(valueB));
100
- })
101
- : filteredData;
102
-
103
- return [
104
- {
105
- key: 'collection-all',
106
- label: 'All Events',
107
- value: null,
108
- items: sortedData,
109
- } as PivotGroup<TItem>,
110
- ];
111
- }, [filteredData, activeDimension]);
112
-
113
- return {
114
- filteredData,
115
- allGroupsForBuckets,
116
- groupedGroups,
117
- collectionGroup,
118
- };
119
- }
@@ -1,163 +0,0 @@
1
- // Copyright (c) Cratis. All rights reserved.
2
- // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
-
4
- import { useState, useRef, useCallback } from 'react';
5
-
6
- export function usePanning(
7
- containerRef: React.RefObject<HTMLDivElement | null>,
8
- onBackgroundClick?: () => void,
9
- onScrollChange?: (scroll: { x: number; y: number }) => void
10
- ) {
11
- const [isPanning, setIsPanning] = useState(false);
12
- const panStartRef = useRef<{ x: number; y: number; scrollLeft: number; scrollTop: number } | null>(null);
13
- const velocityRef = useRef({ x: 0, y: 0 });
14
- const lastMouseRef = useRef<{ x: number; y: number; time: number } | null>(null);
15
- const inertiaAnimationRef = useRef<number | null>(null);
16
- const didDragRef = useRef(false);
17
- const clickedOnBackgroundRef = useRef(false);
18
-
19
- const stopInertia = useCallback(() => {
20
- if (inertiaAnimationRef.current) {
21
- cancelAnimationFrame(inertiaAnimationRef.current);
22
- inertiaAnimationRef.current = null;
23
- }
24
- }, []);
25
-
26
- const handlePanStart = useCallback((e: React.MouseEvent | MouseEvent, isExplicitlyOnCard?: boolean) => {
27
- const container = containerRef.current;
28
- if (!container) return;
29
-
30
- stopInertia();
31
-
32
- const target = e.target as HTMLElement;
33
- // Check if explicitly on card (from Pixi) or via DOM (fallback)
34
- const isOnCard = isExplicitlyOnCard ?? !!target.closest('.pv-card');
35
-
36
- clickedOnBackgroundRef.current = !isOnCard;
37
- didDragRef.current = false;
38
- velocityRef.current = { x: 0, y: 0 };
39
-
40
- if (e.button === 1 || (e.button === 0 && (e.altKey || !isOnCard))) {
41
- if (!isOnCard) {
42
- (e as any).preventDefault?.();
43
- }
44
- setIsPanning(true);
45
- panStartRef.current = {
46
- x: e.clientX,
47
- y: e.clientY,
48
- scrollLeft: container.scrollLeft,
49
- scrollTop: container.scrollTop,
50
- };
51
- lastMouseRef.current = { x: e.clientX, y: e.clientY, time: performance.now() };
52
- }
53
- }, [containerRef, stopInertia]);
54
-
55
- const handlePanMove = useCallback((e: React.MouseEvent | MouseEvent) => {
56
- const panStart = panStartRef.current;
57
- if (!isPanning || !panStart) return;
58
-
59
- const dx = e.clientX - panStart.x;
60
- const dy = e.clientY - panStart.y;
61
-
62
- // Mark as dragged if moved more than 3 pixels
63
- if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
64
- didDragRef.current = true;
65
- }
66
-
67
- // Camera moves opposite to drag direction
68
- const newCameraX = panStart.scrollLeft - dx;
69
- const newCameraY = panStart.scrollTop - dy;
70
-
71
- // Update camera position
72
- // Update container scroll directly so the visual camera follows the drag
73
- const container = containerRef.current;
74
- if (container) {
75
- container.scrollLeft = Math.max(0, Math.round(newCameraX));
76
- container.scrollTop = Math.max(0, Math.round(newCameraY));
77
- }
78
-
79
- // Also notify parent about scroll change (keeps external state in sync)
80
- if (onScrollChange) {
81
- onScrollChange({ x: container ? container.scrollLeft : newCameraX, y: container ? container.scrollTop : newCameraY });
82
- }
83
-
84
- // Track velocity for inertia
85
- const now = performance.now();
86
- const last = lastMouseRef.current;
87
- if (last) {
88
- const dt = now - last.time;
89
- if (dt > 0 && dt < 50) {
90
- const instantVx = (last.x - e.clientX) / dt;
91
- const instantVy = (last.y - e.clientY) / dt;
92
- velocityRef.current = {
93
- x: velocityRef.current.x * 0.5 + instantVx * 0.5,
94
- y: velocityRef.current.y * 0.5 + instantVy * 0.5,
95
- };
96
- }
97
- }
98
- lastMouseRef.current = { x: e.clientX, y: e.clientY, time: now };
99
- }, [isPanning, containerRef, onScrollChange]);
100
-
101
- const handlePanEnd = useCallback(() => {
102
- const wasPanning = isPanning;
103
- const velocity = { ...velocityRef.current };
104
-
105
- setIsPanning(false);
106
- panStartRef.current = null;
107
-
108
- // If clicked on background and didn't drag, trigger deselect
109
- if (clickedOnBackgroundRef.current && !didDragRef.current && onBackgroundClick) {
110
- onBackgroundClick();
111
- }
112
-
113
- if (!wasPanning) {
114
- return;
115
- }
116
-
117
- const container = containerRef.current;
118
- if (!container) {
119
- lastMouseRef.current = null;
120
- return;
121
- }
122
-
123
- const speed = Math.sqrt(velocity.x * velocity.x + velocity.y * velocity.y);
124
-
125
- // Start inertia if moving fast enough
126
- if (speed > 0.3) {
127
- let vx = velocity.x * 16;
128
- let vy = velocity.y * 16;
129
-
130
- const animate = () => {
131
- const currentSpeed = Math.sqrt(vx * vx + vy * vy);
132
-
133
- if (currentSpeed < 0.5) {
134
- inertiaAnimationRef.current = null;
135
- return;
136
- }
137
-
138
- container.scrollLeft += vx;
139
- container.scrollTop += vy;
140
-
141
- // Decay
142
- vx *= 0.95;
143
- vy *= 0.95;
144
-
145
- inertiaAnimationRef.current = requestAnimationFrame(animate);
146
- };
147
-
148
- inertiaAnimationRef.current = requestAnimationFrame(animate);
149
- }
150
-
151
- lastMouseRef.current = null;
152
- }, [isPanning, containerRef, onBackgroundClick]);
153
-
154
- return {
155
- isPanning,
156
- handlePanStart,
157
- handlePanMove,
158
- handlePanEnd,
159
- };
160
- }
161
-
162
-
163
-