@sequent-org/moodboard 1.3.5 → 1.4.1

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 (65) hide show
  1. package/package.json +6 -1
  2. package/src/assets/icons/mindmap.svg +3 -0
  3. package/src/core/SaveManager.js +44 -15
  4. package/src/core/commands/MindmapStatePatchCommand.js +85 -0
  5. package/src/core/commands/UpdateContentCommand.js +47 -4
  6. package/src/core/flows/LayerAndViewportFlow.js +87 -14
  7. package/src/core/flows/ObjectLifecycleFlow.js +7 -2
  8. package/src/core/flows/SaveFlow.js +10 -7
  9. package/src/core/flows/TransformFlow.js +2 -2
  10. package/src/core/index.js +81 -11
  11. package/src/core/rendering/ObjectRenderer.js +7 -2
  12. package/src/grid/BaseGrid.js +65 -0
  13. package/src/grid/CrossGrid.js +89 -24
  14. package/src/grid/CrossGridZoomPhases.js +167 -0
  15. package/src/grid/DotGrid.js +117 -34
  16. package/src/grid/DotGridZoomPhases.js +214 -16
  17. package/src/grid/GridDiagnostics.js +80 -0
  18. package/src/grid/GridFactory.js +13 -11
  19. package/src/grid/LineGrid.js +176 -37
  20. package/src/grid/LineGridZoomPhases.js +163 -0
  21. package/src/grid/ScreenGridPhaseMachine.js +51 -0
  22. package/src/mindmap/MindmapCompoundContract.js +235 -0
  23. package/src/moodboard/ActionHandler.js +1 -0
  24. package/src/moodboard/DataManager.js +57 -0
  25. package/src/moodboard/bootstrap/MoodBoardUiFactory.js +21 -0
  26. package/src/moodboard/integration/MoodBoardEventBindings.js +82 -1
  27. package/src/moodboard/lifecycle/MoodBoardDestroyer.js +15 -0
  28. package/src/objects/MindmapObject.js +76 -0
  29. package/src/objects/ObjectFactory.js +3 -1
  30. package/src/services/BoardService.js +127 -31
  31. package/src/services/GridSnapResolver.js +60 -0
  32. package/src/services/MiroZoomLevels.js +39 -0
  33. package/src/services/SettingsApplier.js +0 -4
  34. package/src/services/ZoomPanController.js +51 -32
  35. package/src/tools/object-tools/PlacementTool.js +12 -3
  36. package/src/tools/object-tools/SelectTool.js +11 -1
  37. package/src/tools/object-tools/placement/GhostController.js +100 -1
  38. package/src/tools/object-tools/placement/PlacementEventsBridge.js +2 -0
  39. package/src/tools/object-tools/placement/PlacementInputRouter.js +2 -2
  40. package/src/tools/object-tools/selection/FileNameInlineEditorController.js +2 -2
  41. package/src/tools/object-tools/selection/InlineEditorController.js +15 -0
  42. package/src/tools/object-tools/selection/MindmapInlineEditorController.js +757 -0
  43. package/src/tools/object-tools/selection/SelectInputRouter.js +20 -1
  44. package/src/tools/object-tools/selection/SelectToolSetup.js +2 -0
  45. package/src/tools/object-tools/selection/SelectionStateController.js +7 -0
  46. package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +12 -16
  47. package/src/ui/ContextMenu.js +6 -6
  48. package/src/ui/DotGridDebugPanel.js +253 -0
  49. package/src/ui/HtmlTextLayer.js +1 -1
  50. package/src/ui/TextPropertiesPanel.js +2 -2
  51. package/src/ui/handles/GroupSelectionHandlesController.js +4 -1
  52. package/src/ui/handles/HandlesDomRenderer.js +1485 -14
  53. package/src/ui/handles/HandlesEventBridge.js +49 -5
  54. package/src/ui/handles/HandlesInteractionController.js +4 -4
  55. package/src/ui/mindmap/MindmapConnectionLayer.js +254 -0
  56. package/src/ui/mindmap/MindmapHtmlTextLayer.js +285 -0
  57. package/src/ui/mindmap/MindmapLayoutConfig.js +29 -0
  58. package/src/ui/mindmap/MindmapTextOverlayAdapter.js +144 -0
  59. package/src/ui/styles/toolbar.css +1 -0
  60. package/src/ui/styles/workspace.css +100 -0
  61. package/src/ui/toolbar/ToolbarActionRouter.js +35 -0
  62. package/src/ui/toolbar/ToolbarPopupsController.js +6 -6
  63. package/src/ui/toolbar/ToolbarRenderer.js +1 -0
  64. package/src/ui/toolbar/ToolbarStateController.js +1 -0
  65. package/src/utils/iconLoader.js +10 -4
@@ -1,4 +1,6 @@
1
1
  import { BaseGrid } from './BaseGrid.js';
2
+ import { getScreenAnchor } from './ScreenGridPhaseMachine.js';
3
+ import { resolveLineGridState } from './LineGridZoomPhases.js';
2
4
 
3
5
  /**
4
6
  * Линейная прямоугольная сетка
@@ -7,16 +9,30 @@ export class LineGrid extends BaseGrid {
7
9
  constructor(options = {}) {
8
10
  super(options);
9
11
  this.type = 'line';
10
-
11
- // Дополнительные настройки для линейной сетки
12
- // Параметры не задаём по умолчанию здесь — их поставляет GridFactory.
13
- this.showSubGrid = options.showSubGrid;
14
- this.subGridDivisions = options.subGridDivisions;
15
- this.subGridColor = options.subGridColor;
16
- this.subGridOpacity = options.subGridOpacity;
17
- this.lineWidth = options.lineWidth;
18
- this.color = options.color;
19
- this.opacity = options.opacity;
12
+
13
+ // Жесткий Miro-профиль line-grid: не зависим от сохраненных override.
14
+ this.showSubGrid = false;
15
+ this.subGridDivisions = 0;
16
+ this.subGridColor = 0xFEFEFE;
17
+ this.subGridOpacity = 1;
18
+ this.bigGridColor = 0xECECEC;
19
+ this.bigGridOpacity = 1;
20
+ this.lineWidth = 1;
21
+ this.color = 0xF4F4F4;
22
+ this.opacity = 1;
23
+
24
+ this._majorAnchorX = null;
25
+ this._majorAnchorY = null;
26
+ this._majorStepX = null;
27
+ this._majorStepY = null;
28
+ this._minorAnchorX = null;
29
+ this._minorAnchorY = null;
30
+ this._minorStepX = null;
31
+ this._minorStepY = null;
32
+ this._majorCursorOffsetX = 0;
33
+ this._majorCursorOffsetY = 0;
34
+ this._minorCursorOffsetX = 0;
35
+ this._minorCursorOffsetY = 0;
20
36
  }
21
37
 
22
38
  /**
@@ -27,20 +43,26 @@ export class LineGrid extends BaseGrid {
27
43
  if (typeof this.opacity === 'number') {
28
44
  this.graphics.alpha = this.opacity;
29
45
  }
46
+ const state = this.getScreenGridState();
30
47
  try {
31
- // В новых версиях можно указать alignment для большей чёткости
32
- this.graphics.lineStyle({ width: this.lineWidth, color: this.color, alpha: 1, alignment: 0.5 });
48
+ this.graphics.lineStyle({
49
+ width: Math.max(1, Math.round(this.lineWidth || 1)),
50
+ color: this.color,
51
+ alpha: 1,
52
+ alignment: 0
53
+ });
33
54
  } catch (_) {
34
- this.graphics.lineStyle(this.lineWidth, this.color, 1);
55
+ this.graphics.lineStyle(Math.max(1, Math.round(this.lineWidth || 1)), this.color, 1);
35
56
  }
36
57
 
37
58
  // Основные линии сетки
38
59
  this.drawMainGrid();
39
-
40
- // Дополнительная подсетка
41
- if (this.showSubGrid) {
42
- this.drawSubGrid();
60
+
61
+ // Вторая (крупная) сетка.
62
+ if ((state.secondGridStep || 0) > 0) {
63
+ this.drawSecondGrid();
43
64
  }
65
+
44
66
  }
45
67
 
46
68
  /**
@@ -48,21 +70,32 @@ export class LineGrid extends BaseGrid {
48
70
  */
49
71
  drawMainGrid() {
50
72
  const b = this.getDrawBounds();
51
- const step = this.size;
52
- const half = this.lineWidth / 2;
53
- const startX = Math.floor(b.left / step) * step;
54
- const endX = Math.ceil(b.right / step) * step;
55
- const startY = Math.floor(b.top / step) * step;
56
- const endY = Math.ceil(b.bottom / step) * step;
73
+ const { screenStep } = this.getScreenGridState();
74
+ const step = Math.max(1, screenStep);
75
+ const worldX = this.viewportTransform?.worldX || 0;
76
+ const worldY = this.viewportTransform?.worldY || 0;
77
+ const cursorX = this.viewportTransform?.zoomCursorX;
78
+ const cursorY = this.viewportTransform?.zoomCursorY;
79
+ const useCursorAnchor = this.viewportTransform?.useCursorAnchor === true;
80
+ const anchorX = this._resolveScreenAnchor('x', worldX, step, cursorX, useCursorAnchor, 'major');
81
+ const anchorY = this._resolveScreenAnchor('y', worldY, step, cursorY, useCursorAnchor, 'major');
82
+ const alignStart = (min, anchor) => {
83
+ const d = ((anchor - min) % step + step) % step;
84
+ return min + d;
85
+ };
86
+ const startX = alignStart(b.left, anchorX);
87
+ const endX = b.right + step;
88
+ const startY = alignStart(b.top, anchorY);
89
+ const endY = b.bottom + step;
57
90
  // Вертикальные линии
58
91
  for (let x = startX; x <= endX; x += step) {
59
- const px = Math.round(x) + (Number.isFinite(half) ? 0.5 : 0);
92
+ const px = Math.round(x);
60
93
  this.graphics.moveTo(px, b.top);
61
94
  this.graphics.lineTo(px, b.bottom);
62
95
  }
63
96
  // Горизонтальные линии
64
97
  for (let y = startY; y <= endY; y += step) {
65
- const py = Math.round(y) + (Number.isFinite(half) ? 0.5 : 0);
98
+ const py = Math.round(y);
66
99
  this.graphics.moveTo(b.left, py);
67
100
  this.graphics.lineTo(b.right, py);
68
101
  }
@@ -72,32 +105,136 @@ export class LineGrid extends BaseGrid {
72
105
  * Рисует подсетку (более мелкие линии)
73
106
  */
74
107
  drawSubGrid() {
75
- const subSize = this.size / this.subGridDivisions;
108
+ const { screenStep, minorScreenStep } = this.getScreenGridState();
109
+ const subSize = Math.max(1, Math.round(minorScreenStep || (screenStep / this.subGridDivisions)));
76
110
  try {
77
- this.graphics.lineStyle({ width: 0.5, color: this.subGridColor, alpha: this.subGridOpacity, alignment: 0.5 });
111
+ this.graphics.lineStyle({
112
+ width: 1,
113
+ color: this.subGridColor,
114
+ alpha: this.subGridOpacity,
115
+ alignment: 0
116
+ });
78
117
  } catch (_) {
79
- this.graphics.lineStyle(0.5, this.subGridColor, this.subGridOpacity);
118
+ this.graphics.lineStyle(1, this.subGridColor, this.subGridOpacity);
80
119
  }
81
120
  const b = this.getDrawBounds();
82
- const startX = Math.floor(b.left / subSize) * subSize;
83
- const endX = Math.ceil(b.right / subSize) * subSize;
84
- const startY = Math.floor(b.top / subSize) * subSize;
85
- const endY = Math.ceil(b.bottom / subSize) * subSize;
121
+ const worldX = this.viewportTransform?.worldX || 0;
122
+ const worldY = this.viewportTransform?.worldY || 0;
123
+ const cursorX = this.viewportTransform?.zoomCursorX;
124
+ const cursorY = this.viewportTransform?.zoomCursorY;
125
+ const useCursorAnchor = this.viewportTransform?.useCursorAnchor === true;
126
+ const anchorX = this._resolveScreenAnchor('x', worldX, subSize, cursorX, useCursorAnchor, 'minor');
127
+ const anchorY = this._resolveScreenAnchor('y', worldY, subSize, cursorY, useCursorAnchor, 'minor');
128
+ const alignStart = (min, anchor) => {
129
+ const d = ((anchor - min) % subSize + subSize) % subSize;
130
+ return min + d;
131
+ };
132
+ const startX = alignStart(b.left, anchorX);
133
+ const endX = b.right + subSize;
134
+ const startY = alignStart(b.top, anchorY);
135
+ const endY = b.bottom + subSize;
136
+ const majorStep = Math.max(1, screenStep);
137
+ const majorAnchorX = this._resolveScreenAnchor('x', worldX, majorStep, cursorX, useCursorAnchor, 'major');
138
+ const majorAnchorY = this._resolveScreenAnchor('y', worldY, majorStep, cursorY, useCursorAnchor, 'major');
139
+ const isOnMajor = (value, major, anchor) => {
140
+ const rel = (value - anchor) / major;
141
+ return Math.abs(rel - Math.round(rel)) < 1e-4;
142
+ };
86
143
  for (let x = startX; x <= endX; x += subSize) {
87
- if (x % this.size !== 0) {
88
- const px = Math.round(x) + 0.5;
144
+ if (!isOnMajor(x, majorStep, majorAnchorX)) {
145
+ const px = Math.round(x);
89
146
  this.graphics.moveTo(px, b.top);
90
147
  this.graphics.lineTo(px, b.bottom);
91
148
  }
92
149
  }
93
150
  for (let y = startY; y <= endY; y += subSize) {
94
- if (y % this.size !== 0) {
95
- const py = Math.round(y) + 0.5;
151
+ if (!isOnMajor(y, majorStep, majorAnchorY)) {
152
+ const py = Math.round(y);
96
153
  this.graphics.moveTo(b.left, py);
97
154
  this.graphics.lineTo(b.right, py);
98
155
  }
99
156
  }
100
157
  }
158
+
159
+ drawSecondGrid() {
160
+ const { secondGridStep } = this.getScreenGridState();
161
+ const step = Math.max(1, Math.round(secondGridStep || 0));
162
+ if (step <= 0) return;
163
+ try {
164
+ this.graphics.lineStyle({
165
+ width: 1,
166
+ color: this.bigGridColor,
167
+ alpha: this.bigGridOpacity,
168
+ alignment: 0
169
+ });
170
+ } catch (_) {
171
+ this.graphics.lineStyle(1, this.bigGridColor, this.bigGridOpacity);
172
+ }
173
+ const b = this.getDrawBounds();
174
+ const worldX = this.viewportTransform?.worldX || 0;
175
+ const worldY = this.viewportTransform?.worldY || 0;
176
+ const cursorX = this.viewportTransform?.zoomCursorX;
177
+ const cursorY = this.viewportTransform?.zoomCursorY;
178
+ const useCursorAnchor = this.viewportTransform?.useCursorAnchor === true;
179
+ const anchorX = this._resolveScreenAnchor('x', worldX, step, cursorX, useCursorAnchor, 'major');
180
+ const anchorY = this._resolveScreenAnchor('y', worldY, step, cursorY, useCursorAnchor, 'major');
181
+ const alignStart = (min, anchor) => {
182
+ const d = ((anchor - min) % step + step) % step;
183
+ return min + d;
184
+ };
185
+ const startX = alignStart(b.left, anchorX);
186
+ const endX = b.right + step;
187
+ const startY = alignStart(b.top, anchorY);
188
+ const endY = b.bottom + step;
189
+ for (let x = startX; x <= endX; x += step) {
190
+ const px = Math.round(x);
191
+ this.graphics.moveTo(px, b.top);
192
+ this.graphics.lineTo(px, b.bottom);
193
+ }
194
+ for (let y = startY; y <= endY; y += step) {
195
+ const py = Math.round(y);
196
+ this.graphics.moveTo(b.left, py);
197
+ this.graphics.lineTo(b.right, py);
198
+ }
199
+ }
200
+
201
+ _normalizeAnchor(anchor, stepPx) {
202
+ const step = Math.max(1, Math.round(stepPx));
203
+ return ((Math.round(anchor) % step) + step) % step;
204
+ }
205
+
206
+ _resolveScreenAnchor(axis, worldOffset, stepPx, cursorPx, useCursorAnchor, layer) {
207
+ const step = Math.max(1, Math.round(stepPx));
208
+ const anchorKey = `_${layer}Anchor${axis === 'x' ? 'X' : 'Y'}`;
209
+ const stepKey = `_${layer}Step${axis === 'x' ? 'X' : 'Y'}`;
210
+ const cursorOffsetKey = `_${layer}CursorOffset${axis === 'x' ? 'X' : 'Y'}`;
211
+ const raw = this._normalizeAnchor(getScreenAnchor(worldOffset, step), step);
212
+ if (useCursorAnchor && Number.isFinite(cursorPx)) {
213
+ const prevAnchor = Number(this[anchorKey]);
214
+ const prevStep = Math.max(1, Math.round(Number(this[stepKey]) || step));
215
+ if (Number.isFinite(prevAnchor)) {
216
+ this[cursorOffsetKey] = this._normalizeAnchor(Math.round(cursorPx) - prevAnchor, prevStep);
217
+ } else if (!Number.isFinite(this[cursorOffsetKey])) {
218
+ this[cursorOffsetKey] = 0;
219
+ }
220
+ const offset = this._normalizeAnchor(this[cursorOffsetKey], step);
221
+ const locked = this._normalizeAnchor(Math.round(cursorPx) - offset, step);
222
+ this[anchorKey] = locked;
223
+ this[stepKey] = step;
224
+ return locked;
225
+ }
226
+ this[anchorKey] = raw;
227
+ this[stepKey] = step;
228
+ return raw;
229
+ }
230
+
231
+ getScreenGridState() {
232
+ return resolveLineGridState(this._zoom, {
233
+ minScreenSpacing: this.minScreenSpacing,
234
+ phases: this.screenPhases,
235
+ subGridDivisions: this.subGridDivisions,
236
+ });
237
+ }
101
238
 
102
239
  /**
103
240
  * Вычисляет точку привязки для линейной сетки
@@ -134,7 +271,9 @@ export class LineGrid extends BaseGrid {
134
271
  showSubGrid: this.showSubGrid,
135
272
  subGridDivisions: this.subGridDivisions,
136
273
  subGridColor: this.subGridColor,
137
- subGridOpacity: this.subGridOpacity
274
+ subGridOpacity: this.subGridOpacity,
275
+ bigGridColor: this.bigGridColor,
276
+ bigGridOpacity: this.bigGridOpacity
138
277
  };
139
278
  }
140
279
  }
@@ -0,0 +1,163 @@
1
+ import { resolveScreenGridState } from './ScreenGridPhaseMachine.js';
2
+
3
+ function clampZoom(zoom) {
4
+ return Math.max(0.01, Math.min(5, zoom || 1));
5
+ }
6
+
7
+ const MAJOR_SCREEN_POINTS = [
8
+ { zoomPercent: 1.4, majorPx: 17 },
9
+ { zoomPercent: 1.6, majorPx: 20 },
10
+ { zoomPercent: 1.8, majorPx: 22 },
11
+ { zoomPercent: 2.0, majorPx: 25 },
12
+ { zoomPercent: 2.2, majorPx: 27 },
13
+ { zoomPercent: 2.4, majorPx: 31 },
14
+ { zoomPercent: 2.6, majorPx: 35 },
15
+ { zoomPercent: 3.0, majorPx: 39 },
16
+ { zoomPercent: 3.4, majorPx: 44 },
17
+ { zoomPercent: 3.6, majorPx: 49 },
18
+ { zoomPercent: 4.0, majorPx: 55 },
19
+ { zoomPercent: 4.6, majorPx: 60 }, // label 5 (second pass), single grid
20
+ { zoomPercent: 5.4, majorPx: 17 }, // label 5 (first pass), double grid
21
+ { zoomPercent: 6.0, majorPx: 20 },
22
+ { zoomPercent: 7.0, majorPx: 22 },
23
+ { zoomPercent: 7.6, majorPx: 24 }, // label 8 (second pass)
24
+ { zoomPercent: 8.4, majorPx: 27 }, // label 8 (first pass)
25
+ { zoomPercent: 10.0, majorPx: 30 },
26
+ { zoomPercent: 11.0, majorPx: 34 },
27
+ { zoomPercent: 12.0, majorPx: 38 },
28
+ { zoomPercent: 13.0, majorPx: 42 },
29
+ { zoomPercent: 15.0, majorPx: 48 },
30
+ { zoomPercent: 17.0, majorPx: 54 },
31
+ { zoomPercent: 19.0, majorPx: 60 }, // single grid
32
+ { zoomPercent: 21.0, majorPx: 17 },
33
+ { zoomPercent: 24.0, majorPx: 19 },
34
+ { zoomPercent: 26.0, majorPx: 21 },
35
+ { zoomPercent: 30.0, majorPx: 24 },
36
+ { zoomPercent: 33.0, majorPx: 26 },
37
+ { zoomPercent: 37.0, majorPx: 30 },
38
+ { zoomPercent: 41.0, majorPx: 33 },
39
+ { zoomPercent: 46.0, majorPx: 37 },
40
+ { zoomPercent: 52.0, majorPx: 42 },
41
+ { zoomPercent: 58.0, majorPx: 46 },
42
+ { zoomPercent: 65.0, majorPx: 52 },
43
+ { zoomPercent: 73, majorPx: 60 },
44
+ { zoomPercent: 82, majorPx: 16 },
45
+ { zoomPercent: 92, majorPx: 18 },
46
+ { zoomPercent: 103, majorPx: 20 },
47
+ { zoomPercent: 115, majorPx: 23 },
48
+ { zoomPercent: 129, majorPx: 25 },
49
+ { zoomPercent: 144, majorPx: 30 },
50
+ { zoomPercent: 162, majorPx: 35 },
51
+ { zoomPercent: 181, majorPx: 35 },
52
+ { zoomPercent: 203, majorPx: 40 },
53
+ { zoomPercent: 227, majorPx: 45 },
54
+ { zoomPercent: 254, majorPx: 50 },
55
+ { zoomPercent: 285, majorPx: 60 },
56
+ { zoomPercent: 319, majorPx: 64 },
57
+ { zoomPercent: 357, majorPx: 70 },
58
+ { zoomPercent: 400, majorPx: 80 },
59
+ ];
60
+
61
+ function interpolateMajorStep(zoomPercent) {
62
+ if (zoomPercent <= MAJOR_SCREEN_POINTS[0].zoomPercent) {
63
+ const ratio = zoomPercent / MAJOR_SCREEN_POINTS[0].zoomPercent;
64
+ return Math.max(1, Math.round(MAJOR_SCREEN_POINTS[0].majorPx * ratio));
65
+ }
66
+ if (zoomPercent >= MAJOR_SCREEN_POINTS[MAJOR_SCREEN_POINTS.length - 1].zoomPercent) {
67
+ const ratio = zoomPercent / MAJOR_SCREEN_POINTS[MAJOR_SCREEN_POINTS.length - 1].zoomPercent;
68
+ return Math.max(1, Math.round(MAJOR_SCREEN_POINTS[MAJOR_SCREEN_POINTS.length - 1].majorPx * ratio));
69
+ }
70
+
71
+ for (let i = 0; i < MAJOR_SCREEN_POINTS.length - 1; i += 1) {
72
+ const a = MAJOR_SCREEN_POINTS[i];
73
+ const b = MAJOR_SCREEN_POINTS[i + 1];
74
+ if (zoomPercent >= a.zoomPercent && zoomPercent <= b.zoomPercent) {
75
+ const t = (zoomPercent - a.zoomPercent) / (b.zoomPercent - a.zoomPercent);
76
+ const value = a.majorPx + (b.majorPx - a.majorPx) * t;
77
+ return Math.max(1, Math.round(value));
78
+ }
79
+ }
80
+ return Math.max(1, Math.round(resolveScreenGridState(zoomPercent / 100).screenStep));
81
+ }
82
+
83
+ function normalizeZoomPercent(zoomPercent) {
84
+ return Math.round(zoomPercent * 10) / 10;
85
+ }
86
+
87
+ const EXACT_MAJOR_BY_PERCENT = new Map(
88
+ MAJOR_SCREEN_POINTS.map((row) => [normalizeZoomPercent(row.zoomPercent), row.majorPx])
89
+ );
90
+
91
+ /**
92
+ * Профиль line-grid под наблюдаемые checkpoint'ы из MIRO_LINE_GRID.md.
93
+ * Возвращает major/minor screen-step и видимость подсетки.
94
+ */
95
+ export function resolveLineGridState(zoom, options = {}) {
96
+ const z = clampZoom(zoom);
97
+ const base = resolveScreenGridState(z, options);
98
+ const zoomPercent = z * 100;
99
+
100
+ const roundedPercent = Math.round(zoomPercent);
101
+ const normalizedPercent = normalizeZoomPercent(zoomPercent);
102
+ let majorScreenStep = EXACT_MAJOR_BY_PERCENT.get(normalizedPercent) ?? interpolateMajorStep(zoomPercent);
103
+ const minorScreenStep = null;
104
+ const showSubGridByZoom = false;
105
+ const hasSecondGridByRoundedPercent =
106
+ roundedPercent === 254 ||
107
+ roundedPercent === 227 ||
108
+ roundedPercent === 203 ||
109
+ roundedPercent === 181 ||
110
+ roundedPercent === 162 ||
111
+ roundedPercent === 144 ||
112
+ roundedPercent === 129 ||
113
+ roundedPercent === 115 ||
114
+ roundedPercent === 103 ||
115
+ roundedPercent === 92 ||
116
+ roundedPercent === 82 ||
117
+ roundedPercent === 65 ||
118
+ roundedPercent === 58 ||
119
+ roundedPercent === 52 ||
120
+ roundedPercent === 46 ||
121
+ roundedPercent === 41 ||
122
+ roundedPercent === 37;
123
+ const hasSecondGridByExactPercent =
124
+ normalizedPercent === 33.0 ||
125
+ normalizedPercent === 30.0 ||
126
+ normalizedPercent === 26.0 ||
127
+ normalizedPercent === 24.0 ||
128
+ normalizedPercent === 21.0 ||
129
+ normalizedPercent === 17.0 ||
130
+ normalizedPercent === 15.0 ||
131
+ normalizedPercent === 13.0 ||
132
+ normalizedPercent === 12.0 ||
133
+ normalizedPercent === 11.0 ||
134
+ normalizedPercent === 10.0 ||
135
+ normalizedPercent === 8.4 ||
136
+ normalizedPercent === 7.6 ||
137
+ normalizedPercent === 7.0 ||
138
+ normalizedPercent === 6.0 ||
139
+ normalizedPercent === 5.4 ||
140
+ normalizedPercent === 65.0 ||
141
+ normalizedPercent === 58.0 ||
142
+ normalizedPercent === 52.0 ||
143
+ normalizedPercent === 46.0 ||
144
+ normalizedPercent === 41.0 ||
145
+ normalizedPercent === 37.0;
146
+ const hasSecondGrid = hasSecondGridByRoundedPercent || hasSecondGridByExactPercent;
147
+ const hasSecondGridForUltraLowZoom = normalizedPercent < 5.0 && normalizedPercent !== 4.6;
148
+ const secondGridStep = (roundedPercent === 100 || hasSecondGrid)
149
+ || hasSecondGridForUltraLowZoom
150
+ ? Math.max(1, Math.round(majorScreenStep * 4))
151
+ : null;
152
+
153
+ return {
154
+ ...base,
155
+ screenStep: majorScreenStep,
156
+ majorScreenStep,
157
+ minorScreenStep,
158
+ secondGridStep,
159
+ showSubGridByZoom,
160
+ subDivisions: 0,
161
+ };
162
+ }
163
+
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Общая state machine для screen-grid.
3
+ * Возвращает world-step активной фазы и screen-step (px) для текущего zoom.
4
+ */
5
+
6
+ /** @type {{ zoomMin: number, zoomMax: number, worldStep: number }[]} */
7
+ const DEFAULT_PHASES = [
8
+ { zoomMin: 0.02, zoomMax: 0.12, worldStep: 160 },
9
+ { zoomMin: 0.12, zoomMax: 0.25, worldStep: 80 },
10
+ { zoomMin: 0.25, zoomMax: 0.5, worldStep: 40 },
11
+ { zoomMin: 0.5, zoomMax: 5, worldStep: 20 },
12
+ ];
13
+
14
+ function clampZoom(zoom) {
15
+ return Math.max(0.02, Math.min(5, zoom || 1));
16
+ }
17
+
18
+ function pickPhase(zoom, phases = DEFAULT_PHASES) {
19
+ const z = clampZoom(zoom);
20
+ for (let i = 0; i < phases.length; i++) {
21
+ const phase = phases[i];
22
+ const inRange = i === phases.length - 1
23
+ ? (z >= phase.zoomMin && z <= phase.zoomMax)
24
+ : (z >= phase.zoomMin && z < phase.zoomMax);
25
+ if (inRange) return phase;
26
+ }
27
+ return phases[phases.length - 1];
28
+ }
29
+
30
+ export function resolveScreenGridState(zoom, options = {}) {
31
+ const z = clampZoom(zoom);
32
+ const phases = options.phases || DEFAULT_PHASES;
33
+ const minScreenSpacing = options.minScreenSpacing ?? 8;
34
+ const phase = pickPhase(z, phases);
35
+ const worldStep = phase.worldStep;
36
+ const screenStep = Math.max(minScreenSpacing, worldStep * z);
37
+ return { zoom: z, phase, worldStep, screenStep };
38
+ }
39
+
40
+ export function getScreenAnchor(worldOffset, stepPx) {
41
+ const step = Math.max(1e-6, stepPx || 1);
42
+ const mod = worldOffset % step;
43
+ return mod < 0 ? mod + step : mod;
44
+ }
45
+
46
+ export function snapScreenValue(valuePx, anchorPx, stepPx) {
47
+ const step = Math.max(1e-6, stepPx || 1);
48
+ return Math.round((valuePx - anchorPx) / step) * step + anchorPx;
49
+ }
50
+
51
+ export { DEFAULT_PHASES };