@sequent-org/moodboard 1.3.4 → 1.4.0

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 (64) 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 +26 -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 +716 -0
  43. package/src/tools/object-tools/selection/SelectInputRouter.js +6 -0
  44. package/src/tools/object-tools/selection/SelectToolSetup.js +2 -0
  45. package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +12 -16
  46. package/src/ui/ContextMenu.js +6 -6
  47. package/src/ui/DotGridDebugPanel.js +253 -0
  48. package/src/ui/HtmlTextLayer.js +1 -1
  49. package/src/ui/TextPropertiesPanel.js +2 -2
  50. package/src/ui/handles/GroupSelectionHandlesController.js +4 -1
  51. package/src/ui/handles/HandlesDomRenderer.js +1486 -15
  52. package/src/ui/handles/HandlesEventBridge.js +49 -5
  53. package/src/ui/handles/HandlesInteractionController.js +4 -4
  54. package/src/ui/mindmap/MindmapConnectionLayer.js +239 -0
  55. package/src/ui/mindmap/MindmapHtmlTextLayer.js +285 -0
  56. package/src/ui/mindmap/MindmapLayoutConfig.js +29 -0
  57. package/src/ui/mindmap/MindmapTextOverlayAdapter.js +144 -0
  58. package/src/ui/styles/toolbar.css +1 -0
  59. package/src/ui/styles/workspace.css +100 -0
  60. package/src/ui/toolbar/ToolbarActionRouter.js +35 -0
  61. package/src/ui/toolbar/ToolbarPopupsController.js +6 -6
  62. package/src/ui/toolbar/ToolbarRenderer.js +1 -0
  63. package/src/ui/toolbar/ToolbarStateController.js +1 -0
  64. package/src/utils/iconLoader.js +10 -4
@@ -1,5 +1,9 @@
1
1
  import { BaseGrid } from './BaseGrid.js';
2
- import { getActivePhases, getEffectiveSize } from './DotGridZoomPhases.js';
2
+ import { getActivePhases, getDotCheckpointForZoom, getDotColor, getEffectiveSize, getRawScreenDotRadius, getScreenDotRadius, getScreenSpacing } from './DotGridZoomPhases.js';
3
+ import { getScreenAnchor } from './ScreenGridPhaseMachine.js';
4
+
5
+ /** Checkpoint zoomPercent: 1px dot вместо круга */
6
+ const DOT_1PX_CHECKPOINTS = new Set([7, 7.6, 8.4, 15, 17, 19, 20, 21, 24, 26, 30, 37, 41, 46]);
3
7
 
4
8
  /**
5
9
  * Точечная сетка с фазовым переключением при зуме (как в Miro).
@@ -14,8 +18,7 @@ export class DotGrid extends BaseGrid {
14
18
  this.highlightIntersections = options.highlightIntersections ?? true;
15
19
  this.dotSize = options.dotSize ?? 1; // для serialize/setDotSize; фазы переопределяют при отрисовке
16
20
  this.snapSize = options.snapSize ?? 20; // фиксированный шаг привязки в world-координатах
17
- // Порог видимости точки на экране и лимит плотности отрисовки.
18
- this.minScreenDotRadius = options.minScreenDotRadius ?? 0.45;
21
+ this.minScreenDotRadius = options.minScreenDotRadius ?? 0;
19
22
  this.minScreenSpacing = options.minScreenSpacing ?? 8;
20
23
  this.maxDotsPerPhase = options.maxDotsPerPhase ?? 25000;
21
24
  this.intersectionSize = options.intersectionSize ?? this.dotSize;
@@ -23,6 +26,21 @@ export class DotGrid extends BaseGrid {
23
26
 
24
27
  /** Текущий zoom (scale) для выбора фазы. 1 = 100%. */
25
28
  this._zoom = 1;
29
+
30
+ // DotGrid всегда непрозрачный.
31
+ this.opacity = 1;
32
+ this.graphics.alpha = 1;
33
+
34
+ // Cursor-centric anchor-контракт: при зуме точка под курсором фиксируется.
35
+ this._anchorX = null;
36
+ this._anchorY = null;
37
+ this._lastStepPxX = null;
38
+ this._lastStepPxY = null;
39
+ this._cursorOffsetX = 0;
40
+ this._cursorOffsetY = 0;
41
+
42
+ // Временный debug-режим: разрешает дробный радиус для тонкой подстройки.
43
+ this._allowFloatDotRadius = false;
26
44
  }
27
45
 
28
46
  /**
@@ -49,7 +67,7 @@ export class DotGrid extends BaseGrid {
49
67
  createVisual() {
50
68
  this.size = this._getEffectiveSize();
51
69
  const phases = this._getActivePhases();
52
- const baseOpacity = this.opacity ?? 0.7;
70
+ const baseOpacity = 1;
53
71
  for (const { phase, alpha } of phases) {
54
72
  this._drawPhaseDots(phase, baseOpacity * alpha);
55
73
  }
@@ -60,41 +78,79 @@ export class DotGrid extends BaseGrid {
60
78
  */
61
79
  _drawPhaseDots(phase, alpha) {
62
80
  const b = this.getDrawBounds();
63
- const scale = Math.max(0.001, this._zoom || 1);
64
- const baseSize = phase.size;
65
- const minWorldSpacing = this.minScreenSpacing / scale;
66
- let size = Math.max(baseSize, minWorldSpacing);
67
-
68
- const widthWorld = Math.max(0, b.right - b.left);
69
- const heightWorld = Math.max(0, b.bottom - b.top);
70
- const estimateDots = () => {
71
- const nx = Math.floor(widthWorld / size) + 3;
72
- const ny = Math.floor(heightWorld / size) + 3;
73
- return nx * ny;
74
- };
75
- const dots = estimateDots();
76
- if (dots > this.maxDotsPerPhase) {
77
- const densityFactor = Math.sqrt(dots / this.maxDotsPerPhase);
78
- size *= Math.max(1, densityFactor);
79
- }
81
+ const stepPx = Math.max(1, Math.round(getScreenSpacing(this._zoom)));
82
+
83
+ const worldX = this.viewportTransform?.worldX || 0;
84
+ const worldY = this.viewportTransform?.worldY || 0;
85
+ const cursorX = this.viewportTransform?.zoomCursorX;
86
+ const cursorY = this.viewportTransform?.zoomCursorY;
87
+ const useCursorAnchor = this.viewportTransform?.useCursorAnchor === true;
88
+ const anchorX = this._resolveScreenAnchor('x', worldX, stepPx, cursorX, useCursorAnchor);
89
+ const anchorY = this._resolveScreenAnchor('y', worldY, stepPx, cursorY, useCursorAnchor);
80
90
 
81
- const startX = Math.floor(b.left / size) * size;
82
- const startY = Math.floor(b.top / size) * size;
83
- const endX = Math.ceil(b.right / size) * size;
84
- const endY = Math.ceil(b.bottom / size) * size;
91
+ const alignStart = (min, anchor, step) => {
92
+ const minInt = Math.round(min);
93
+ const d = ((anchor - minInt) % step + step) % step;
94
+ return minInt + d;
95
+ };
96
+ const startX = alignStart(b.left, anchorX, stepPx);
97
+ const startY = alignStart(b.top, anchorY, stepPx);
98
+ const endX = Math.round(b.right) + stepPx;
99
+ const endY = Math.round(b.bottom) + stepPx;
85
100
 
86
- const minWorldRadius = this.minScreenDotRadius / scale;
87
- const maxWorldRadius = size * 0.2;
88
- const dotSize = Math.min(Math.max(phase.dotSize, minWorldRadius), maxWorldRadius);
101
+ const rawDotSize = this._allowFloatDotRadius
102
+ ? getRawScreenDotRadius(this._zoom, this.minScreenDotRadius)
103
+ : getScreenDotRadius(this._zoom, this.minScreenDotRadius);
104
+ const checkpoint = getDotCheckpointForZoom(this._zoom);
105
+ const zoomPct = checkpoint?.zoomPercent ?? 100;
106
+ const use1px = DOT_1PX_CHECKPOINTS.has(zoomPct);
107
+ const dotSize = use1px ? 0.4 : rawDotSize;
108
+ const dotColor = getDotColor(this._zoom, this.color);
89
109
 
90
- this.graphics.beginFill(this.color, alpha);
91
- for (let x = startX; x <= endX; x += size) {
92
- for (let y = startY; y <= endY; y += size) {
110
+ this.graphics.beginFill(dotColor, alpha);
111
+ for (let x = startX; x <= endX; x += stepPx) {
112
+ for (let y = startY; y <= endY; y += stepPx) {
93
113
  this.drawDot(x, y, dotSize);
94
114
  }
95
115
  }
96
116
  this.graphics.endFill();
97
117
  }
118
+
119
+ _normalizeAnchor(anchor, stepPx) {
120
+ const step = Math.max(1, Math.round(stepPx));
121
+ const normalized = ((Math.round(anchor) % step) + step) % step;
122
+ return normalized;
123
+ }
124
+
125
+ _resolveScreenAnchor(axis, worldOffset, stepPx, cursorPx, useCursorAnchor) {
126
+ const anchorKey = axis === 'x' ? '_anchorX' : '_anchorY';
127
+ const lastStepKey = axis === 'x' ? '_lastStepPxX' : '_lastStepPxY';
128
+ const cursorOffsetKey = axis === 'x' ? '_cursorOffsetX' : '_cursorOffsetY';
129
+ const step = Math.max(1, Math.round(stepPx));
130
+ const rawAnchor = this._normalizeAnchor(getScreenAnchor(worldOffset, step), step);
131
+
132
+ // Во время cursor-centric zoom привязываем сетку к экранной позиции курсора.
133
+ // Сохраняем относительное смещение курсор↔узел, чтобы не было "прилипания"
134
+ // узла к курсору, если курсор стоял в пустом месте.
135
+ if (useCursorAnchor && Number.isFinite(cursorPx)) {
136
+ const prevAnchor = Number(this[anchorKey]);
137
+ const prevStep = Math.max(1, Math.round(Number(this[lastStepKey]) || step));
138
+ if (Number.isFinite(prevAnchor)) {
139
+ this[cursorOffsetKey] = this._normalizeAnchor(Math.round(cursorPx) - prevAnchor, prevStep);
140
+ } else if (!Number.isFinite(this[cursorOffsetKey])) {
141
+ this[cursorOffsetKey] = 0;
142
+ }
143
+ const offset = this._normalizeAnchor(this[cursorOffsetKey], step);
144
+ const cursorAnchor = this._normalizeAnchor(Math.round(cursorPx) - offset, step);
145
+ this[anchorKey] = cursorAnchor;
146
+ this[lastStepKey] = step;
147
+ return cursorAnchor;
148
+ }
149
+
150
+ this[anchorKey] = rawAnchor;
151
+ this[lastStepKey] = step;
152
+ return rawAnchor;
153
+ }
98
154
 
99
155
  /**
100
156
  * Рисует выделенные пересечения (каждые 5 точек)
@@ -119,11 +175,20 @@ export class DotGrid extends BaseGrid {
119
175
  * Рисует одну точку
120
176
  */
121
177
  drawDot(x, y, size) {
178
+ const px = Math.round(x);
179
+ const py = Math.round(y);
180
+ const r = this._allowFloatDotRadius
181
+ ? Math.max(0, Number(size) || 0)
182
+ : Math.max(0, Math.round(size));
183
+ if (r < 1) {
184
+ this.graphics.drawRect(px, py, 1, 1);
185
+ return;
186
+ }
122
187
  if (this.dotStyle === 'circle') {
123
- this.graphics.drawCircle(x, y, size);
188
+ this.graphics.drawCircle(px, py, r);
124
189
  } else if (this.dotStyle === 'square') {
125
- const half = size;
126
- this.graphics.drawRect(x - half, y - half, half * 2, half * 2);
190
+ const half = r;
191
+ this.graphics.drawRect(px - half, py - half, half * 2, half * 2);
127
192
  }
128
193
  }
129
194
 
@@ -177,6 +242,24 @@ export class DotGrid extends BaseGrid {
177
242
  this.updateVisual();
178
243
  }
179
244
 
245
+ /**
246
+ * Временная debug-настройка дробного радиуса.
247
+ * @param {boolean} enabled
248
+ */
249
+ setAllowFloatDotRadius(enabled) {
250
+ this._allowFloatDotRadius = enabled === true;
251
+ this.updateVisual();
252
+ }
253
+
254
+ /**
255
+ * DotGrid всегда непрозрачный: внешний вызов setOpacity игнорируем.
256
+ */
257
+ setOpacity() {
258
+ this.opacity = 1;
259
+ this.graphics.alpha = 1;
260
+ this.updateVisual();
261
+ }
262
+
180
263
  // Делаем цвет пересечений таким же, как основной цвет
181
264
  setColor(color) {
182
265
  super.setColor(color);
@@ -1,8 +1,5 @@
1
1
  /**
2
2
  * Логика фаз точечной сетки при зуме (чистые функции, тестируемые без PIXI).
3
- * Фазы подобраны по зафиксированным checkpoint'ам Miro:
4
- * - high zoom: базовый шаг 20 world units;
5
- * - low zoom: шаг укрупняется, чтобы избежать перегруза при отрисовке.
6
3
  */
7
4
 
8
5
  /** @type {{ zoomMin: number, zoomMax: number, size: number, dotSize: number }[]} */
@@ -10,26 +7,130 @@ export const PHASES = [
10
7
  { zoomMin: 0.02, zoomMax: 0.12, size: 160, dotSize: 0.7 },
11
8
  { zoomMin: 0.12, zoomMax: 0.25, size: 80, dotSize: 0.8 },
12
9
  { zoomMin: 0.25, zoomMax: 0.5, size: 40, dotSize: 0.9 },
13
- { zoomMin: 0.5, zoomMax: 5, size: 20, dotSize: 1 }
10
+ { zoomMin: 0.5, zoomMax: 5, size: 20, dotSize: 1 },
14
11
  ];
15
12
 
13
+ /**
14
+ * Дискретный профиль dot-grid по zoom checkpoint'ам:
15
+ * на каждом шаге фиксированы spacing / dotRadius / color.
16
+ */
17
+ export const DOT_CHECKPOINTS = [
18
+ { zoomPercent: 10, spacing: 16, dotRadius: 0.5, color: 0xA3A3A3 },
19
+ { zoomPercent: 11, spacing: 16, dotRadius: 0.5, color: 0xA3A3A3 },
20
+ { zoomPercent: 12, spacing: 18, dotRadius: 0.5, color: 0xA3A3A3 },
21
+ { zoomPercent: 13, spacing: 10, dotRadius: 0.5, color: 0xA3A3A3 },
22
+ { zoomPercent: 15, spacing: 12, dotRadius: 0.5, color: 0xA3A3A3 },
23
+ { zoomPercent: 17, spacing: 14, dotRadius: 1, color: 0xA3A3A3 },
24
+ { zoomPercent: 19, spacing: 16, dotRadius: 1, color: 0xA3A3A3 },
25
+ { zoomPercent: 20, spacing: 16, dotRadius: 0.5, color: 0xA3A3A3 },
26
+ { zoomPercent: 21, spacing: 18, dotRadius: 1, color: 0xA3A3A3 },
27
+ { zoomPercent: 24, spacing: 20, dotRadius: 1, color: 0xA3A3A3 },
28
+ { zoomPercent: 26, spacing: 16, dotRadius: 1, color: 0xA3A3A3 },
29
+ { zoomPercent: 30, spacing: 12, dotRadius: 1, color: 0xA3A3A3 },
30
+ { zoomPercent: 33, spacing: 13, dotRadius: 0.5, color: 0xA3A3A3 },
31
+ { zoomPercent: 37, spacing: 16, dotRadius: 1, color: 0xA3A3A3 },
32
+ { zoomPercent: 41, spacing: 18, dotRadius: 1, color: 0xA3A3A3 },
33
+ { zoomPercent: 46, spacing: 20, dotRadius: 1, color: 0xA3A3A3 },
34
+ { zoomPercent: 50, spacing: 10, dotRadius: 0.5, color: 0xA3A3A3 },
35
+ { zoomPercent: 52, spacing: 11, dotRadius: 0.5, color: 0xA3A3A3 },
36
+ { zoomPercent: 58, spacing: 12, dotRadius: 1, color: 0xE8E8E8 },
37
+ { zoomPercent: 1.4, spacing: 16, dotRadius: 0.5, color: 0xA3A3A3 },
38
+ { zoomPercent: 1.6, spacing: 18, dotRadius: 0.5, color: 0xA3A3A3 },
39
+ { zoomPercent: 1.8, spacing: 8, dotRadius: 0.5, color: 0xA3A3A3 },
40
+ { zoomPercent: 2.0, spacing: 10, dotRadius: 0.5, color: 0xA3A3A3 },
41
+ { zoomPercent: 2.2, spacing: 12, dotRadius: 0.5, color: 0xA3A3A3 },
42
+ { zoomPercent: 2.4, spacing: 14, dotRadius: 0.5, color: 0xA3A3A3 },
43
+ { zoomPercent: 2.6, spacing: 16, dotRadius: 0.5, color: 0xA3A3A3 },
44
+ { zoomPercent: 3.0, spacing: 18, dotRadius: 0.5, color: 0xA3A3A3 },
45
+ { zoomPercent: 3.4, spacing: 8, dotRadius: 0.5, color: 0xA3A3A3 },
46
+ { zoomPercent: 3.6, spacing: 10, dotRadius: 0.5, color: 0xA3A3A3 },
47
+ { zoomPercent: 4.0, spacing: 12, dotRadius: 0.5, color: 0xA3A3A3 },
48
+ { zoomPercent: 4.6, spacing: 14, dotRadius: 0.5, color: 0xA3A3A3 },
49
+ { zoomPercent: 5.4, spacing: 16, dotRadius: 0.5, color: 0xA3A3A3 },
50
+ { zoomPercent: 6.0, spacing: 18, dotRadius: 0.5, color: 0xA3A3A3 },
51
+ { zoomPercent: 7.0, spacing: 8, dotRadius: 1, color: 0xA3A3A3 },
52
+ { zoomPercent: 7.6, spacing: 10, dotRadius: 1, color: 0xA3A3A3 },
53
+ { zoomPercent: 8.4, spacing: 12, dotRadius: 1, color: 0xA3A3A3 },
54
+ { zoomPercent: 73, spacing: 15, dotRadius: 1, color: 0xE8E8E8 },
55
+ { zoomPercent: 75, spacing: 15, dotRadius: 1, color: 0xE8E8E8 },
56
+ { zoomPercent: 82, spacing: 16, dotRadius: 1, color: 0xE8E8E8 },
57
+ { zoomPercent: 92, spacing: 18, dotRadius: 1.2, color: 0xE8E8E8 },
58
+ { zoomPercent: 100, spacing: 20, dotRadius: 1, color: 0xE8E8E8 },
59
+ { zoomPercent: 103, spacing: 21, dotRadius: 1.3, color: 0xE8E8E8 },
60
+ { zoomPercent: 115, spacing: 23, dotRadius: 1.4, color: 0xE8E8E8 },
61
+ { zoomPercent: 125, spacing: 25, dotRadius: 1, color: 0xE8E8E8 },
62
+ { zoomPercent: 129, spacing: 26, dotRadius: 1.5, color: 0xE3E3E3 },
63
+ { zoomPercent: 144, spacing: 29, dotRadius: 1.7, color: 0xE7E7E7 },
64
+ { zoomPercent: 150, spacing: 30, dotRadius: 2, color: 0xE8E8E8 },
65
+ { zoomPercent: 162, spacing: 32, dotRadius: 1.5, color: 0xE5E5E5 },
66
+ { zoomPercent: 181, spacing: 36, dotRadius: 2, color: 0xE2E2E2 },
67
+ { zoomPercent: 200, spacing: 40, dotRadius: 2, color: 0xE8E8E8 },
68
+ { zoomPercent: 250, spacing: 50, dotRadius: 2, color: 0xE8E8E8 },
69
+ { zoomPercent: 300, spacing: 60, dotRadius: 3, color: 0xE8E8E8 },
70
+ { zoomPercent: 400, spacing: 80, dotRadius: 4, color: 0xE8E8E8 },
71
+ { zoomPercent: 500, spacing: 100, dotRadius: 5, color: 0xE8E8E8 },
72
+ ];
73
+
74
+ function clampZoom(zoom) {
75
+ return Math.max(0.02, Math.min(5, zoom || 1));
76
+ }
77
+
78
+ /**
79
+ * Для DotGrid границы фаз выбираются по "нижней" фазе (включительно),
80
+ * чтобы на 50% происходил ожидаемый визуальный скачок.
81
+ */
82
+ function resolveDotPhase(zoom) {
83
+ const z = clampZoom(zoom);
84
+ for (const phase of PHASES) {
85
+ if (z >= phase.zoomMin && z <= phase.zoomMax) {
86
+ return phase;
87
+ }
88
+ }
89
+ return PHASES[PHASES.length - 1];
90
+ }
91
+
92
+ function resolveDotCheckpoint(zoom) {
93
+ const z = clampZoom(zoom);
94
+ const p = z * 100;
95
+ let best = DOT_CHECKPOINTS[0];
96
+ let bestDist = Math.abs(best.zoomPercent - p);
97
+ for (let i = 1; i < DOT_CHECKPOINTS.length; i += 1) {
98
+ const row = DOT_CHECKPOINTS[i];
99
+ const dist = Math.abs(row.zoomPercent - p);
100
+ if (dist < bestDist) {
101
+ best = row;
102
+ bestDist = dist;
103
+ }
104
+ }
105
+ return best;
106
+ }
107
+
108
+ function sanitizeColor(color, fallback = 0xE8E8E8) {
109
+ const n = Number(color);
110
+ if (!Number.isFinite(n)) return fallback;
111
+ return Math.max(0, Math.min(0xFFFFFF, Math.round(n)));
112
+ }
113
+
114
+ function sanitizeSpacing(spacing, fallback = 20) {
115
+ const n = Number(spacing);
116
+ if (!Number.isFinite(n)) return fallback;
117
+ return Math.max(1, Math.round(n));
118
+ }
119
+
120
+ function sanitizeDotRadius(radius, fallback = 1) {
121
+ const n = Number(radius);
122
+ if (!Number.isFinite(n)) return fallback;
123
+ return Math.max(0, n);
124
+ }
125
+
16
126
  /**
17
127
  * Возвращает активные фазы и их alpha для crossfade.
18
128
  * @param {number} zoom - world.scale.x (1 = 100%)
19
129
  * @returns {Array<{ phase: object, alpha: number }>}
20
130
  */
21
131
  export function getActivePhases(zoom) {
22
- const z = Math.max(0.02, Math.min(5, zoom));
23
- for (let i = 0; i < PHASES.length; i++) {
24
- const phase = PHASES[i];
25
- const inRange = i === PHASES.length - 1
26
- ? (z >= phase.zoomMin && z <= phase.zoomMax)
27
- : (z >= phase.zoomMin && z < phase.zoomMax);
28
- if (inRange) {
29
- return [{ phase, alpha: 1 }];
30
- }
31
- }
32
- return [{ phase: PHASES[PHASES.length - 1], alpha: 1 }];
132
+ const phase = resolveDotPhase(zoom);
133
+ return [{ phase, alpha: 1 }];
33
134
  }
34
135
 
35
136
  /**
@@ -38,5 +139,102 @@ export function getActivePhases(zoom) {
38
139
  * @returns {number}
39
140
  */
40
141
  export function getEffectiveSize(zoom) {
41
- return getActivePhases(zoom)[0].phase.size;
142
+ return resolveDotPhase(zoom).size;
143
+ }
144
+
145
+ /**
146
+ * Возвращает текущий checkpoint (копию) для zoom.
147
+ * @param {number} zoom
148
+ * @returns {{ zoomPercent:number, spacing:number, dotRadius:number, color:number }}
149
+ */
150
+ export function getDotCheckpointForZoom(zoom) {
151
+ const row = resolveDotCheckpoint(zoom);
152
+ return {
153
+ zoomPercent: row.zoomPercent,
154
+ spacing: row.spacing,
155
+ dotRadius: row.dotRadius,
156
+ color: row.color,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Обновляет checkpoint для заданного zoomPercent.
162
+ * Используется debug-панелью для live-настройки.
163
+ * @param {number} zoomPercent
164
+ * @param {{ spacing?: number, dotRadius?: number, color?: number }} patch
165
+ * @returns {{ zoomPercent:number, spacing:number, dotRadius:number, color:number } | null}
166
+ */
167
+ export function updateDotCheckpoint(zoomPercent, patch = {}) {
168
+ const target = Number(zoomPercent);
169
+ if (!Number.isFinite(target)) return null;
170
+ const row = DOT_CHECKPOINTS.find((r) => Math.abs(r.zoomPercent - target) < 1e-6);
171
+ if (!row) return null;
172
+ if (Object.prototype.hasOwnProperty.call(patch, 'spacing')) {
173
+ row.spacing = sanitizeSpacing(patch.spacing, row.spacing);
174
+ }
175
+ if (Object.prototype.hasOwnProperty.call(patch, 'dotRadius')) {
176
+ row.dotRadius = sanitizeDotRadius(patch.dotRadius, row.dotRadius);
177
+ }
178
+ if (Object.prototype.hasOwnProperty.call(patch, 'color')) {
179
+ row.color = sanitizeColor(patch.color, row.color);
180
+ }
181
+ return {
182
+ zoomPercent: row.zoomPercent,
183
+ spacing: row.spacing,
184
+ dotRadius: row.dotRadius,
185
+ color: row.color,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Возвращает screen-spacing в px для текущего zoom.
191
+ * @param {number} zoom
192
+ * @param {number} minScreenSpacing
193
+ * @returns {number}
194
+ */
195
+ export function getScreenSpacing(zoom) {
196
+ return resolveDotCheckpoint(zoom).spacing;
197
+ }
198
+
199
+ /**
200
+ * Возвращает integer-радиус точки в screen-space для текущего zoom.
201
+ * @param {number} zoom
202
+ * @param {number} minScreenDotRadius
203
+ * @returns {number}
204
+ */
205
+ export function getScreenDotRadius(zoom, minScreenDotRadius = 1) {
206
+ const stepRadius = resolveDotCheckpoint(zoom).dotRadius;
207
+ return Math.max(0, Math.round(stepRadius));
208
+ }
209
+
210
+ /**
211
+ * Возвращает raw-радиус без округления (для debug-просмотра).
212
+ * @param {number} zoom
213
+ * @param {number} minScreenDotRadius
214
+ * @returns {number}
215
+ */
216
+ export function getRawScreenDotRadius(zoom, minScreenDotRadius = 0.1) {
217
+ const stepRadius = resolveDotCheckpoint(zoom).dotRadius;
218
+ return Math.max(0, Number(stepRadius) || 0);
219
+ }
220
+
221
+ /**
222
+ * Возвращает цвет точки для текущего zoom-checkpoint.
223
+ * @param {number} zoom
224
+ * @param {number} fallbackColor
225
+ * @returns {number}
226
+ */
227
+ export function getDotColor(zoom, fallbackColor = 0xE8E8E8) {
228
+ const color = resolveDotCheckpoint(zoom).color;
229
+ return Number.isFinite(color) ? color : fallbackColor;
230
+ }
231
+
232
+ /**
233
+ * Точечная сетка всегда рендерится без прозрачности.
234
+ * @param {number} zoom
235
+ * @returns {number}
236
+ */
237
+ export function getDotOpacity(zoom) {
238
+ void zoom;
239
+ return 1;
42
240
  }
@@ -0,0 +1,80 @@
1
+ const GLOBAL_DIAG_KEY = '__MOODBOARD_GRID_DIAGNOSTICS__';
2
+ const MAX_HISTORY = 500;
3
+
4
+ function getGlobalScope() {
5
+ try {
6
+ return globalThis;
7
+ } catch (_) {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ function normalizeEnabled(raw) {
13
+ if (raw === true || raw === '1' || raw === 1) return true;
14
+ if (raw === false || raw === '0' || raw === 0) return false;
15
+ return false;
16
+ }
17
+
18
+ function ensureSink() {
19
+ const scope = getGlobalScope();
20
+ if (!scope) return null;
21
+ const existing = scope[GLOBAL_DIAG_KEY];
22
+ if (existing && typeof existing === 'object') {
23
+ if (typeof existing.enabled !== 'boolean') existing.enabled = false;
24
+ if (!existing.counters || typeof existing.counters !== 'object') existing.counters = {};
25
+ if (!Array.isArray(existing.history)) existing.history = [];
26
+ return existing;
27
+ }
28
+ const created = {
29
+ enabled: false,
30
+ counters: {},
31
+ history: [],
32
+ };
33
+ scope[GLOBAL_DIAG_KEY] = created;
34
+ return created;
35
+ }
36
+
37
+ export function isGridDiagnosticsEnabled() {
38
+ const envEnabled = normalizeEnabled(
39
+ (typeof process !== 'undefined' && process?.env?.MOODBOARD_GRID_DIAGNOSTICS) || false
40
+ );
41
+ if (envEnabled) return true;
42
+ const sink = ensureSink();
43
+ return !!sink?.enabled;
44
+ }
45
+
46
+ export function incrementGridDiagnosticCounter(counterName) {
47
+ if (!counterName || !isGridDiagnosticsEnabled()) return;
48
+ const sink = ensureSink();
49
+ if (!sink) return;
50
+ sink.counters[counterName] = (sink.counters[counterName] || 0) + 1;
51
+ }
52
+
53
+ export function logGridDiagnostic(scope, message, data = undefined) {
54
+ if (!isGridDiagnosticsEnabled()) return;
55
+ const sink = ensureSink();
56
+ if (!sink) return;
57
+
58
+ const entry = {
59
+ ts: Date.now(),
60
+ scope,
61
+ message,
62
+ data,
63
+ };
64
+ sink.history.push(entry);
65
+ if (sink.history.length > MAX_HISTORY) {
66
+ sink.history.splice(0, sink.history.length - MAX_HISTORY);
67
+ }
68
+ }
69
+
70
+ export function getGridDiagnosticsSnapshot() {
71
+ const sink = ensureSink();
72
+ if (!sink) {
73
+ return { enabled: false, counters: {}, history: [] };
74
+ }
75
+ return {
76
+ enabled: !!sink.enabled,
77
+ counters: { ...sink.counters },
78
+ history: [...sink.history],
79
+ };
80
+ }
@@ -67,18 +67,20 @@ export class GridFactory {
67
67
  const defaults = {
68
68
  line: {
69
69
  enabled: true,
70
- size: 32,
71
- color: 0x6a6aff,
72
- opacity: 0.4,
70
+ size: 20,
71
+ color: 0xF4F4F4,
72
+ opacity: 1,
73
73
  lineWidth: 1,
74
- showSubGrid: false,
75
- subGridDivisions: 4
74
+ showSubGrid: true,
75
+ subGridDivisions: 5,
76
+ subGridColor: 0xFEFEFE,
77
+ subGridOpacity: 1
76
78
  },
77
79
  dot: {
78
80
  enabled: true,
79
81
  size: 30,
80
- color: 0x6a6aff,
81
- opacity: 0.7,
82
+ color: 0xE8E8E8,
83
+ opacity: 1,
82
84
  dotSize: 1,
83
85
  dotStyle: 'circle',
84
86
  highlightIntersections: true
@@ -86,7 +88,7 @@ export class GridFactory {
86
88
  cross: {
87
89
  enabled: true,
88
90
  size: 65, // шаг между крестиками
89
- color: 0xB8BAFF, // целевой цвет
91
+ color: 0xE8E8E8, // как у dot-сетки
90
92
  opacity: 1, // непрозрачно
91
93
  crossHalfSize: 5, // длина луча (половина креста) в px
92
94
  crossLineWidth: 1
@@ -130,21 +132,21 @@ export class GridFactory {
130
132
  type: 'dot',
131
133
  size: 10,
132
134
  color: 0xC0C0C0,
133
- opacity: 0.6,
135
+ opacity: 1,
134
136
  dotSize: 1
135
137
  },
136
138
  'standard-dots': {
137
139
  type: 'dot',
138
140
  size: 20,
139
141
  color: 0xB0B0B0,
140
- opacity: 0.7,
142
+ opacity: 1,
141
143
  dotSize: 2
142
144
  },
143
145
  'bold-dots': {
144
146
  type: 'dot',
145
147
  size: 30,
146
148
  color: 0xA0A0A0,
147
- opacity: 0.8,
149
+ opacity: 1,
148
150
  dotSize: 3,
149
151
  dotStyle: 'square'
150
152
  },