@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
package/src/core/index.js CHANGED
@@ -7,6 +7,7 @@ import { HistoryManager } from './HistoryManager.js';
7
7
  import { ApiClient } from './ApiClient.js';
8
8
  import { ImageUploadService } from '../services/ImageUploadService.js';
9
9
  import { FileUploadService } from '../services/FileUploadService.js';
10
+ import { GridSnapResolver } from '../services/GridSnapResolver.js';
10
11
  import { CreateObjectCommand, DeleteObjectCommand, MoveObjectCommand, PasteObjectCommand } from './commands/index.js';
11
12
  import { Events } from './events/Events.js';
12
13
  import { generateObjectId } from '../utils/objectIdGenerator.js';
@@ -17,6 +18,10 @@ import { setupObjectLifecycleFlow } from './flows/ObjectLifecycleFlow.js';
17
18
  import { setupLayerAndViewportFlow } from './flows/LayerAndViewportFlow.js';
18
19
  import { setupRevitFlow } from './flows/RevitFlow.js';
19
20
  import { setupSaveFlow } from './flows/SaveFlow.js';
21
+ import {
22
+ logMindmapCompoundDebug,
23
+ normalizeMindmapPropertiesForCreate,
24
+ } from '../mindmap/MindmapCompoundContract.js';
20
25
 
21
26
  export class CoreMoodBoard {
22
27
  constructor(container, options = {}) {
@@ -60,6 +65,7 @@ export class CoreMoodBoard {
60
65
  requireCsrf: this.options.requireCsrf !== false, // По умолчанию требуем CSRF
61
66
  csrfToken: this.options.csrfToken
62
67
  });
68
+ this.gridSnapResolver = new GridSnapResolver(this);
63
69
 
64
70
  // Связываем SaveManager с ApiClient для правильной обработки изображений
65
71
  this.saveManager.setApiClient(this.apiClient);
@@ -212,6 +218,14 @@ export class CoreMoodBoard {
212
218
  this._historyEventsInitialized = true;
213
219
  // Следим за изменениями истории для обновления UI
214
220
  this.eventBus.on(Events.History.Changed, (data) => {
221
+ if (typeof data?.currentCommand === 'string' && data.currentCommand.toLowerCase().includes('mindmap')) {
222
+ logMindmapCompoundDebug('history:changed', {
223
+ currentCommand: data.currentCommand,
224
+ canUndo: !!data.canUndo,
225
+ canRedo: !!data.canRedo,
226
+ historySize: data.historySize,
227
+ });
228
+ }
215
229
 
216
230
 
217
231
  // Можно здесь обновить состояние кнопок Undo/Redo в UI
@@ -243,22 +257,30 @@ export class CoreMoodBoard {
243
257
  * Прямое обновление позиции объекта (без команды)
244
258
  * Используется во время перетаскивания для плавного движения
245
259
  */
246
- updateObjectPositionDirect(objectId, position) {
260
+ updateObjectPositionDirect(objectId, position, options = {}) {
247
261
  // position — левый верх (state); приводим к центру в PIXI, используя размеры PIXI объекта
248
262
  // Все объекты используют pivot по центру, поэтому логика одинакова для всех
249
263
  const pixiObject = this.pixi.objects.get(objectId);
264
+ let nextPosition = position;
265
+ const applySnap = options.snap !== false;
266
+ if (applySnap && pixiObject && this.gridSnapResolver) {
267
+ nextPosition = this.gridSnapResolver.snapWorldTopLeft(position, {
268
+ width: pixiObject.width || 0,
269
+ height: pixiObject.height || 0,
270
+ });
271
+ }
250
272
  if (pixiObject) {
251
273
  const halfW = (pixiObject.width || 0) / 2;
252
274
  const halfH = (pixiObject.height || 0) / 2;
253
- pixiObject.x = position.x + halfW;
254
- pixiObject.y = position.y + halfH;
275
+ pixiObject.x = nextPosition.x + halfW;
276
+ pixiObject.y = nextPosition.y + halfH;
255
277
  }
256
278
 
257
279
  // Обновляем позицию в состоянии (без эмита события)
258
280
  const objects = this.state.state.objects;
259
281
  const object = objects.find(obj => obj.id === objectId);
260
282
  if (object) {
261
- object.position = { ...position };
283
+ object.position = { ...nextPosition };
262
284
  this.state.markDirty(); // Помечаем для автосохранения
263
285
  }
264
286
  }
@@ -282,7 +304,7 @@ export class CoreMoodBoard {
282
304
  * Прямое обновление размера и позиции объекта (без команды)
283
305
  * Используется во время изменения размера для плавного изменения
284
306
  */
285
- updateObjectSizeAndPositionDirect(objectId, size, position = null, objectType = null) {
307
+ updateObjectSizeAndPositionDirect(objectId, size, position = null, objectType = null, options = {}) {
286
308
  // Обновляем размер в PIXI
287
309
  const pixiObject = this.pixi.objects.get(objectId);
288
310
  const prevCenter = pixiObject ? { x: pixiObject.x, y: pixiObject.y } : null;
@@ -290,19 +312,23 @@ export class CoreMoodBoard {
290
312
 
291
313
  // Обновляем позицию если передана (state: левый-верх; PIXI: центр)
292
314
  if (position) {
315
+ const applySnap = options.snap !== false;
316
+ const snappedPosition = (applySnap && this.gridSnapResolver)
317
+ ? this.gridSnapResolver.snapWorldTopLeft(position, size)
318
+ : position;
293
319
  const pixiObject2 = this.pixi.objects.get(objectId);
294
320
  if (pixiObject2) {
295
321
  const halfW = (size?.width ?? pixiObject2.width ?? 0) / 2;
296
322
  const halfH = (size?.height ?? pixiObject2.height ?? 0) / 2;
297
- pixiObject2.x = position.x + halfW;
298
- pixiObject2.y = position.y + halfH;
323
+ pixiObject2.x = snappedPosition.x + halfW;
324
+ pixiObject2.y = snappedPosition.y + halfH;
299
325
 
300
326
  // Обновляем позицию в состоянии
301
327
  const objects = this.state.state.objects;
302
328
  const object = objects.find(obj => obj.id === objectId);
303
329
  if (object) {
304
- object.position.x = position.x;
305
- object.position.y = position.y;
330
+ object.position.x = snappedPosition.x;
331
+ object.position.y = snappedPosition.y;
306
332
  }
307
333
  }
308
334
  } else if (prevCenter) {
@@ -373,19 +399,44 @@ export class CoreMoodBoard {
373
399
  properties = { ...(properties || {}), title: 'Фрейм 1' };
374
400
  }
375
401
  }
402
+ const snappedCreatePos = this.gridSnapResolver
403
+ ? this.gridSnapResolver.snapWorldTopLeft(position, {
404
+ width: initialWidth,
405
+ height: initialHeight,
406
+ })
407
+ : position;
376
408
  const objectData = {
377
409
  id: generateObjectId(exists),
378
410
  type,
379
- position,
411
+ position: snappedCreatePos,
380
412
  width: initialWidth,
381
413
  height: initialHeight,
382
- properties,
414
+ properties: normalizeMindmapPropertiesForCreate({
415
+ type,
416
+ objectId: null,
417
+ properties,
418
+ existingObjects: this.state?.state?.objects || [],
419
+ }),
383
420
  created: new Date().toISOString(),
384
421
  transform: {
385
422
  pivotCompensated: false // Новые объекты еще не скомпенсированы
386
423
  },
387
424
  ...extraData // Добавляем дополнительные данные (например, imageId)
388
425
  };
426
+ objectData.properties = normalizeMindmapPropertiesForCreate({
427
+ type,
428
+ objectId: objectData.id,
429
+ properties: objectData.properties,
430
+ existingObjects: this.state?.state?.objects || [],
431
+ });
432
+ if (type === 'mindmap') {
433
+ logMindmapCompoundDebug('core:create-object', {
434
+ id: objectData.id,
435
+ role: objectData.properties?.mindmap?.role || null,
436
+ compoundId: objectData.properties?.mindmap?.compoundId || null,
437
+ parentId: objectData.properties?.mindmap?.parentId || null,
438
+ });
439
+ }
389
440
 
390
441
  // Создаем и выполняем команду создания объекта
391
442
  const command = new CreateObjectCommand(this, objectData);
@@ -440,6 +491,20 @@ export class CoreMoodBoard {
440
491
  if (objectData.transform.pivotCompensated === undefined) {
441
492
  objectData.transform.pivotCompensated = false;
442
493
  }
494
+ objectData.properties = normalizeMindmapPropertiesForCreate({
495
+ type: objectData.type,
496
+ objectId: objectData.id || null,
497
+ properties: objectData.properties || {},
498
+ existingObjects: this.state?.state?.objects || [],
499
+ });
500
+ if (objectData.type === 'mindmap') {
501
+ logMindmapCompoundDebug('core:load-object', {
502
+ id: objectData.id,
503
+ role: objectData.properties?.mindmap?.role || null,
504
+ compoundId: objectData.properties?.mindmap?.compoundId || null,
505
+ parentId: objectData.properties?.mindmap?.parentId || null,
506
+ });
507
+ }
443
508
 
444
509
  // Используем существующие данные объекта (с его ID, размерами и т.д.)
445
510
  this.state.addObject(objectData);
@@ -618,6 +683,11 @@ export class CoreMoodBoard {
618
683
  this.frameService.detach();
619
684
  this.frameService = null;
620
685
  }
686
+
687
+ if (this.boardService) {
688
+ this.boardService.destroy?.();
689
+ this.boardService = null;
690
+ }
621
691
 
622
692
  if (this.pixi) {
623
693
  this.pixi.destroy();
@@ -83,8 +83,9 @@ export class ObjectRenderer {
83
83
  pixiObject.anchor.set(0.5, 0.5);
84
84
  } else if (pixiObject instanceof PIXI.Graphics) {
85
85
  const bounds = pixiObject.getBounds();
86
- const pivotX = bounds.width / 2;
87
- const pivotY = bounds.height / 2;
86
+ const isMindmap = objectData?.type === 'mindmap';
87
+ const pivotX = isMindmap ? Math.floor(bounds.width / 2) : (bounds.width / 2);
88
+ const pivotY = isMindmap ? Math.floor(bounds.height / 2) : (bounds.height / 2);
88
89
  pixiObject.pivot.set(pivotX, pivotY);
89
90
 
90
91
  // Компенсируем смещение pivot
@@ -92,6 +93,10 @@ export class ObjectRenderer {
92
93
  if (needsCompensation) {
93
94
  pixiObject.x += pivotX;
94
95
  pixiObject.y += pivotY;
96
+ if (isMindmap) {
97
+ pixiObject.x = Math.round(pixiObject.x);
98
+ pixiObject.y = Math.round(pixiObject.y);
99
+ }
95
100
  }
96
101
  }
97
102
 
@@ -1,4 +1,11 @@
1
1
  import * as PIXI from 'pixi.js';
2
+ import {
3
+ DEFAULT_PHASES,
4
+ getScreenAnchor,
5
+ resolveScreenGridState,
6
+ snapScreenValue,
7
+ } from './ScreenGridPhaseMachine.js';
8
+ import { incrementGridDiagnosticCounter, logGridDiagnostic } from './GridDiagnostics.js';
2
9
 
3
10
  /**
4
11
  * Базовый класс для всех типов сеток
@@ -19,6 +26,16 @@ export class BaseGrid {
19
26
  this.height = options.height || 1080;
20
27
  /** @type {{ left: number, top: number, right: number, bottom: number } | null} */
21
28
  this.viewportBounds = null;
29
+ this.viewportTransform = {
30
+ worldX: 0,
31
+ worldY: 0,
32
+ scale: 1,
33
+ viewWidth: this.width,
34
+ viewHeight: this.height,
35
+ };
36
+ this._zoom = 1;
37
+ this.minScreenSpacing = options.minScreenSpacing ?? 8;
38
+ this.screenPhases = options.screenPhases || DEFAULT_PHASES;
22
39
 
23
40
  // PIXI графика
24
41
  this.graphics = new PIXI.Graphics();
@@ -62,6 +79,34 @@ export class BaseGrid {
62
79
 
63
80
  return this.calculateSnapPoint(x, y);
64
81
  }
82
+
83
+ /**
84
+ * Привязывает world-координаты к screen-grid контракту.
85
+ */
86
+ snapWorldPoint(x, y) {
87
+ incrementGridDiagnosticCounter('baseGrid.snapWorldPoint.calls');
88
+ const t = this.viewportTransform || { worldX: 0, worldY: 0, scale: 1 };
89
+ const scale = Math.max(0.001, t.scale || 1);
90
+ const screenX = (x * scale) + (t.worldX || 0);
91
+ const screenY = (y * scale) + (t.worldY || 0);
92
+ const { screenStep } = this.getScreenGridState();
93
+ const anchorX = getScreenAnchor(t.worldX || 0, screenStep);
94
+ const anchorY = getScreenAnchor(t.worldY || 0, screenStep);
95
+ const snappedScreenX = snapScreenValue(screenX, anchorX, screenStep);
96
+ const snappedScreenY = snapScreenValue(screenY, anchorY, screenStep);
97
+ const snapped = {
98
+ x: (snappedScreenX - (t.worldX || 0)) / scale,
99
+ y: (snappedScreenY - (t.worldY || 0)) / scale,
100
+ };
101
+ logGridDiagnostic('BaseGrid', 'snapWorldPoint', {
102
+ type: this.type,
103
+ scale,
104
+ screenStep,
105
+ input: { x, y },
106
+ output: snapped,
107
+ });
108
+ return snapped;
109
+ }
65
110
 
66
111
  /**
67
112
  * Вычисляет точку привязки к сетке
@@ -112,6 +157,10 @@ export class BaseGrid {
112
157
  this.size = Math.max(1, size);
113
158
  this.updateVisual();
114
159
  }
160
+
161
+ setZoom(scale) {
162
+ this._zoom = Math.max(0.01, Math.min(5, scale || 1));
163
+ }
115
164
 
116
165
  /**
117
166
  * Устанавливает цвет сетки
@@ -135,10 +184,19 @@ export class BaseGrid {
135
184
  resize(width, height) {
136
185
  this.width = width;
137
186
  this.height = height;
187
+ this.viewportTransform.viewWidth = width;
188
+ this.viewportTransform.viewHeight = height;
138
189
  this.viewportBounds = null;
139
190
  this.updateVisual();
140
191
  }
141
192
 
193
+ setViewportTransform(transform = {}) {
194
+ this.viewportTransform = {
195
+ ...this.viewportTransform,
196
+ ...transform,
197
+ };
198
+ }
199
+
142
200
  /**
143
201
  * Устанавливает видимую область для непрерывной отрисовки при паннинге.
144
202
  * @param {number} left
@@ -161,6 +219,13 @@ export class BaseGrid {
161
219
  }
162
220
  return { left: 0, top: 0, right: this.width, bottom: this.height };
163
221
  }
222
+
223
+ getScreenGridState() {
224
+ return resolveScreenGridState(this._zoom, {
225
+ minScreenSpacing: this.minScreenSpacing,
226
+ phases: this.screenPhases,
227
+ });
228
+ }
164
229
 
165
230
  /**
166
231
  * Возвращает PIXI объект для рендеринга
@@ -1,5 +1,6 @@
1
- import * as PIXI from 'pixi.js';
2
1
  import { BaseGrid } from './BaseGrid.js';
2
+ import { getScreenAnchor } from './ScreenGridPhaseMachine.js';
3
+ import { getCrossCheckpointForZoom, getCrossColor } from './CrossGridZoomPhases.js';
3
4
 
4
5
  /**
5
6
  * Сетка с крестиками (плюсами) в узлах
@@ -11,12 +12,23 @@ export class CrossGrid extends BaseGrid {
11
12
 
12
13
  // Размер половины креста от центра до конца линии
13
14
  this.crossHalfSize = options.crossHalfSize || 4;
14
- this.crossLineWidth = options.crossLineWidth || this.lineWidth || 1;
15
+ this.crossLineWidth = 1;
15
16
 
16
17
  // По умолчанию делаем цвет крестиков серым
17
18
  if (options.color == null) {
18
19
  this.color = 0xB0B0B0;
19
20
  }
21
+ // CrossGrid всегда непрозрачный, независимо от входящих настроек.
22
+ this.opacity = 1;
23
+ this.graphics.alpha = 1;
24
+
25
+ // Cursor-centric anchor-контракт: при зуме узел под курсором фиксируется.
26
+ this._anchorX = null;
27
+ this._anchorY = null;
28
+ this._lastStepPxX = null;
29
+ this._lastStepPxY = null;
30
+ this._cursorOffsetX = 0;
31
+ this._cursorOffsetY = 0;
20
32
  }
21
33
 
22
34
  /**
@@ -24,31 +36,74 @@ export class CrossGrid extends BaseGrid {
24
36
  */
25
37
  createVisual() {
26
38
  const g = this.graphics;
27
- // Прозрачность через alpha графики (как у линейной сетки)
28
- g.alpha = this.opacity;
29
- // Тонкие чёткие линии как у линейной сетки: alignment = 0.5
30
- try {
31
- g.lineStyle({ width: Math.max(0.5, this.crossLineWidth), color: this.color, alpha: 1, alignment: 0.5 });
32
- } catch (_) {
33
- g.lineStyle(Math.max(0.5, this.crossLineWidth), this.color, 1);
34
- }
39
+ // Строго без прозрачности.
40
+ g.alpha = 1;
41
+ const lineColor = getCrossColor(this._zoom, this.color);
42
+ g.beginFill(lineColor, 1);
35
43
 
36
- const hs = this.crossHalfSize;
44
+ const checkpoint = getCrossCheckpointForZoom(this._zoom);
45
+ const hs = Math.max(1, Math.round(checkpoint.crossHalfSize || this.crossHalfSize || 1));
37
46
  const b = this.getDrawBounds();
38
- const startX = Math.floor(b.left / this.size) * this.size;
39
- const startY = Math.floor(b.top / this.size) * this.size;
40
- const endX = Math.ceil(b.right / this.size) * this.size;
41
- const endY = Math.ceil(b.bottom / this.size) * this.size;
42
- for (let x = startX; x <= endX; x += this.size) {
43
- for (let y = startY; y <= endY; y += this.size) {
44
- const px = Math.round(x) + 0.5;
45
- const py = Math.round(y) + 0.5;
46
- g.moveTo(px - hs, py);
47
- g.lineTo(px + hs, py);
48
- g.moveTo(px, py - hs);
49
- g.lineTo(px, py + hs);
47
+ const step = Math.max(1, Math.round(checkpoint.spacing || 20));
48
+ const worldX = this.viewportTransform?.worldX || 0;
49
+ const worldY = this.viewportTransform?.worldY || 0;
50
+ const cursorX = this.viewportTransform?.zoomCursorX;
51
+ const cursorY = this.viewportTransform?.zoomCursorY;
52
+ const useCursorAnchor = this.viewportTransform?.useCursorAnchor === true;
53
+ const anchorX = this._resolveScreenAnchor('x', worldX, step, cursorX, useCursorAnchor);
54
+ const anchorY = this._resolveScreenAnchor('y', worldY, step, cursorY, useCursorAnchor);
55
+ const alignStart = (min, anchor) => {
56
+ const d = ((anchor - min) % step + step) % step;
57
+ return min + d;
58
+ };
59
+ const startX = alignStart(b.left, anchorX);
60
+ const startY = alignStart(b.top, anchorY);
61
+ const endX = b.right + step;
62
+ const endY = b.bottom + step;
63
+ for (let x = startX; x <= endX; x += step) {
64
+ for (let y = startY; y <= endY; y += step) {
65
+ const px = Math.round(x);
66
+ const py = Math.round(y);
67
+ const ray = hs * 2 + 1;
68
+ // Строгий 1px-контракт: рисуем пиксельные прямоугольники вместо stroke-линий.
69
+ g.drawRect(px - hs, py, ray, 1);
70
+ g.drawRect(px, py - hs, 1, ray);
50
71
  }
72
+
51
73
  }
74
+ g.endFill();
75
+ }
76
+
77
+ _normalizeAnchor(anchor, stepPx) {
78
+ const step = Math.max(1, Math.round(stepPx));
79
+ return ((Math.round(anchor) % step) + step) % step;
80
+ }
81
+
82
+ _resolveScreenAnchor(axis, worldOffset, stepPx, cursorPx, useCursorAnchor) {
83
+ const anchorKey = axis === 'x' ? '_anchorX' : '_anchorY';
84
+ const lastStepKey = axis === 'x' ? '_lastStepPxX' : '_lastStepPxY';
85
+ const cursorOffsetKey = axis === 'x' ? '_cursorOffsetX' : '_cursorOffsetY';
86
+ const step = Math.max(1, Math.round(stepPx));
87
+ const rawAnchor = this._normalizeAnchor(getScreenAnchor(worldOffset, step), step);
88
+
89
+ if (useCursorAnchor && Number.isFinite(cursorPx)) {
90
+ const prevAnchor = Number(this[anchorKey]);
91
+ const prevStep = Math.max(1, Math.round(Number(this[lastStepKey]) || step));
92
+ if (Number.isFinite(prevAnchor)) {
93
+ this[cursorOffsetKey] = this._normalizeAnchor(Math.round(cursorPx) - prevAnchor, prevStep);
94
+ } else if (!Number.isFinite(this[cursorOffsetKey])) {
95
+ this[cursorOffsetKey] = 0;
96
+ }
97
+ const offset = this._normalizeAnchor(this[cursorOffsetKey], step);
98
+ const cursorAnchor = this._normalizeAnchor(Math.round(cursorPx) - offset, step);
99
+ this[anchorKey] = cursorAnchor;
100
+ this[lastStepKey] = step;
101
+ return cursorAnchor;
102
+ }
103
+
104
+ this[anchorKey] = rawAnchor;
105
+ this[lastStepKey] = step;
106
+ return rawAnchor;
52
107
  }
53
108
 
54
109
  /**
@@ -69,7 +124,17 @@ export class CrossGrid extends BaseGrid {
69
124
  }
70
125
 
71
126
  setCrossLineWidth(w) {
72
- this.crossLineWidth = Math.max(1, w);
127
+ void w;
128
+ this.crossLineWidth = 1;
129
+ this.updateVisual();
130
+ }
131
+
132
+ /**
133
+ * CrossGrid всегда непрозрачный: внешний setOpacity игнорируется.
134
+ */
135
+ setOpacity() {
136
+ this.opacity = 1;
137
+ this.graphics.alpha = 1;
73
138
  this.updateVisual();
74
139
  }
75
140
 
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Дискретный профиль cross-grid по zoom checkpoint'ам.
3
+ * Возвращает фиксированные screen-space параметры:
4
+ * - spacing: расстояние между крестами в px
5
+ * - crossHalfSize: половина длины креста в px
6
+ * - color: цвет креста (без автозатемнения)
7
+ */
8
+
9
+ function clampZoom(zoom) {
10
+ return Math.max(0.01, Math.min(5, zoom || 1));
11
+ }
12
+
13
+ /**
14
+ * В таблице используются zoom в процентах (1 = 100%).
15
+ * Для дублей лейблов (8, 5, 4, 3, 2) используются существующие
16
+ * "двойные проходы" из low-zoom checkpoint'ов проекта.
17
+ */
18
+ export const CROSS_CHECKPOINTS = [
19
+ { zoomPercent: 400, crossHalfSize: 8, spacing: 100 },
20
+ { zoomPercent: 357, crossHalfSize: 8, spacing: 90 },
21
+ { zoomPercent: 319, crossHalfSize: 8, spacing: 85 },
22
+ { zoomPercent: 285, crossHalfSize: 8, spacing: 80 },
23
+ { zoomPercent: 254, crossHalfSize: 8, spacing: 75 },
24
+ { zoomPercent: 227, crossHalfSize: 8, spacing: 70 },
25
+ { zoomPercent: 203, crossHalfSize: 6, spacing: 65 },
26
+ { zoomPercent: 181, crossHalfSize: 6, spacing: 60 },
27
+ { zoomPercent: 162, crossHalfSize: 6, spacing: 58 },
28
+ { zoomPercent: 144, crossHalfSize: 6, spacing: 56 },
29
+ { zoomPercent: 129, crossHalfSize: 6, spacing: 54 },
30
+ { zoomPercent: 115, crossHalfSize: 6, spacing: 52 },
31
+ { zoomPercent: 103, crossHalfSize: 4, spacing: 50 },
32
+ { zoomPercent: 82, crossHalfSize: 4, spacing: 48 },
33
+ { zoomPercent: 73, crossHalfSize: 4, spacing: 46 },
34
+ { zoomPercent: 65, crossHalfSize: 4, spacing: 42 },
35
+ { zoomPercent: 58, crossHalfSize: 4, spacing: 40 },
36
+ { zoomPercent: 52, crossHalfSize: 4, spacing: 38 },
37
+ { zoomPercent: 46, crossHalfSize: 4, spacing: 36 },
38
+ { zoomPercent: 41, crossHalfSize: 4, spacing: 34 },
39
+ { zoomPercent: 37, crossHalfSize: 4, spacing: 32 },
40
+ { zoomPercent: 33, crossHalfSize: 4, spacing: 30 },
41
+ { zoomPercent: 30, crossHalfSize: 4, spacing: 28 },
42
+ { zoomPercent: 26, crossHalfSize: 3, spacing: 26 },
43
+ { zoomPercent: 24, crossHalfSize: 3, spacing: 24 },
44
+ { zoomPercent: 21, crossHalfSize: 3, spacing: 22 },
45
+ { zoomPercent: 19, crossHalfSize: 3, spacing: 20 },
46
+ { zoomPercent: 17, crossHalfSize: 3, spacing: 18 },
47
+ { zoomPercent: 15, crossHalfSize: 3, spacing: 16 },
48
+ { zoomPercent: 13, crossHalfSize: 2, spacing: 14 },
49
+ { zoomPercent: 12, crossHalfSize: 2, spacing: 12 },
50
+ { zoomPercent: 11, crossHalfSize: 3, spacing: 28 },
51
+ { zoomPercent: 10, crossHalfSize: 3, spacing: 26 },
52
+ { zoomPercent: 8.4, crossHalfSize: 3, spacing: 24 },
53
+ { zoomPercent: 7.6, crossHalfSize: 3, spacing: 22 },
54
+ { zoomPercent: 7.0, crossHalfSize: 3, spacing: 20 },
55
+ { zoomPercent: 6.0, crossHalfSize: 3, spacing: 18 },
56
+ { zoomPercent: 5.4, crossHalfSize: 3, spacing: 16 },
57
+ { zoomPercent: 4.6, crossHalfSize: 2, spacing: 14 },
58
+ { zoomPercent: 4.0, crossHalfSize: 2, spacing: 12 },
59
+ { zoomPercent: 3.6, crossHalfSize: 3, spacing: 28 },
60
+ { zoomPercent: 3.4, crossHalfSize: 3, spacing: 26 },
61
+ { zoomPercent: 3.0, crossHalfSize: 3, spacing: 24 },
62
+ { zoomPercent: 2.6, crossHalfSize: 3, spacing: 22 },
63
+ { zoomPercent: 2.4, crossHalfSize: 3, spacing: 20 },
64
+ { zoomPercent: 2.2, crossHalfSize: 3, spacing: 18 },
65
+ { zoomPercent: 2.0, crossHalfSize: 3, spacing: 16 },
66
+ { zoomPercent: 1.8, crossHalfSize: 2, spacing: 14 },
67
+ { zoomPercent: 1.6, crossHalfSize: 2, spacing: 12 },
68
+ { zoomPercent: 1.4, crossHalfSize: 2, spacing: 10 },
69
+ ];
70
+
71
+ function sanitizeInt(value, fallback) {
72
+ const n = Number(value);
73
+ if (!Number.isFinite(n)) return fallback;
74
+ return Math.max(1, Math.round(n));
75
+ }
76
+
77
+ function sanitizeColor(value, fallback = 0xB0B0B0) {
78
+ const n = Number(value);
79
+ if (!Number.isFinite(n)) return fallback;
80
+ return Math.max(0, Math.min(0xFFFFFF, Math.round(n)));
81
+ }
82
+
83
+ const DEFAULT_CROSS_COLOR = 0xC2C2C2; // 194,194,194
84
+ const DEFAULT_MID_CROSS_COLOR = 0xA7A7A7; // 167,167,167
85
+ const DEFAULT_SMALL_CROSS_COLOR = 0xCDCDCD; // 205,205,205
86
+ const COLOR_BAND_HIGH = 'high';
87
+ const COLOR_BAND_MID = 'mid';
88
+ const COLOR_BAND_SMALL = 'small';
89
+
90
+ const CROSS_COLOR_BANDS = {
91
+ [COLOR_BAND_HIGH]: DEFAULT_CROSS_COLOR, // большой зум / крупная сетка
92
+ [COLOR_BAND_MID]: DEFAULT_MID_CROSS_COLOR, // средний участок
93
+ [COLOR_BAND_SMALL]: DEFAULT_SMALL_CROSS_COLOR, // малая сетка
94
+ };
95
+
96
+ function resolveCrossCheckpoint(zoom) {
97
+ const z = clampZoom(zoom);
98
+ const p = z * 100;
99
+ let best = CROSS_CHECKPOINTS[0];
100
+ let bestDist = Math.abs(best.zoomPercent - p);
101
+ for (let i = 1; i < CROSS_CHECKPOINTS.length; i += 1) {
102
+ const row = CROSS_CHECKPOINTS[i];
103
+ const dist = Math.abs(row.zoomPercent - p);
104
+ if (dist < bestDist) {
105
+ best = row;
106
+ bestDist = dist;
107
+ }
108
+ }
109
+ return best;
110
+ }
111
+
112
+ export function getCrossCheckpointForZoom(zoom) {
113
+ const row = resolveCrossCheckpoint(zoom);
114
+ const spacing = sanitizeInt(row.spacing, 20);
115
+ const band = resolveColorBandBySpacing(spacing);
116
+ return {
117
+ zoomPercent: row.zoomPercent,
118
+ crossHalfSize: sanitizeInt(row.crossHalfSize, 4),
119
+ spacing,
120
+ color: sanitizeColor(CROSS_COLOR_BANDS[band], DEFAULT_CROSS_COLOR),
121
+ };
122
+ }
123
+
124
+ export function getCrossScreenSpacing(zoom) {
125
+ return getCrossCheckpointForZoom(zoom).spacing;
126
+ }
127
+
128
+ export function getCrossHalfSize(zoom) {
129
+ return getCrossCheckpointForZoom(zoom).crossHalfSize;
130
+ }
131
+
132
+ function resolveColorBandBySpacing(spacing) {
133
+ const s = sanitizeInt(spacing, 20);
134
+ if (s >= 52) return COLOR_BAND_HIGH;
135
+ if (s >= 30) return COLOR_BAND_MID;
136
+ return COLOR_BAND_SMALL;
137
+ }
138
+
139
+ export function getCrossColor(zoom, fallbackColor = DEFAULT_CROSS_COLOR) {
140
+ const checkpoint = getCrossCheckpointForZoom(zoom);
141
+ const band = resolveColorBandBySpacing(checkpoint.spacing);
142
+ return sanitizeColor(CROSS_COLOR_BANDS[band], fallbackColor);
143
+ }
144
+
145
+ export function updateCrossCheckpoint(zoomPercent, patch = {}) {
146
+ const target = Number(zoomPercent);
147
+ if (!Number.isFinite(target)) return null;
148
+ const row = CROSS_CHECKPOINTS.find((r) => Math.abs(r.zoomPercent - target) < 1e-6);
149
+ if (!row) return null;
150
+ if (Object.prototype.hasOwnProperty.call(patch, 'crossHalfSize')) {
151
+ row.crossHalfSize = sanitizeInt(patch.crossHalfSize, row.crossHalfSize);
152
+ }
153
+ if (Object.prototype.hasOwnProperty.call(patch, 'spacing')) {
154
+ row.spacing = sanitizeInt(patch.spacing, row.spacing);
155
+ }
156
+ if (Object.prototype.hasOwnProperty.call(patch, 'color')) {
157
+ const band = resolveColorBandBySpacing(row.spacing);
158
+ CROSS_COLOR_BANDS[band] = sanitizeColor(patch.color, CROSS_COLOR_BANDS[band] ?? DEFAULT_CROSS_COLOR);
159
+ }
160
+ const band = resolveColorBandBySpacing(row.spacing);
161
+ return {
162
+ zoomPercent: row.zoomPercent,
163
+ crossHalfSize: sanitizeInt(row.crossHalfSize, 4),
164
+ spacing: sanitizeInt(row.spacing, 20),
165
+ color: sanitizeColor(CROSS_COLOR_BANDS[band], DEFAULT_CROSS_COLOR),
166
+ };
167
+ }