@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.
- package/package.json +6 -1
- package/src/assets/icons/mindmap.svg +3 -0
- package/src/core/SaveManager.js +44 -15
- package/src/core/commands/MindmapStatePatchCommand.js +85 -0
- package/src/core/commands/UpdateContentCommand.js +47 -4
- package/src/core/flows/LayerAndViewportFlow.js +87 -14
- package/src/core/flows/ObjectLifecycleFlow.js +7 -2
- package/src/core/flows/SaveFlow.js +10 -7
- package/src/core/flows/TransformFlow.js +2 -2
- package/src/core/index.js +81 -11
- package/src/core/rendering/ObjectRenderer.js +7 -2
- package/src/grid/BaseGrid.js +65 -0
- package/src/grid/CrossGrid.js +89 -24
- package/src/grid/CrossGridZoomPhases.js +167 -0
- package/src/grid/DotGrid.js +117 -34
- package/src/grid/DotGridZoomPhases.js +214 -16
- package/src/grid/GridDiagnostics.js +80 -0
- package/src/grid/GridFactory.js +13 -11
- package/src/grid/LineGrid.js +176 -37
- package/src/grid/LineGridZoomPhases.js +163 -0
- package/src/grid/ScreenGridPhaseMachine.js +51 -0
- package/src/mindmap/MindmapCompoundContract.js +235 -0
- package/src/moodboard/ActionHandler.js +1 -0
- package/src/moodboard/DataManager.js +57 -0
- package/src/moodboard/bootstrap/MoodBoardUiFactory.js +21 -0
- package/src/moodboard/integration/MoodBoardEventBindings.js +82 -1
- package/src/moodboard/lifecycle/MoodBoardDestroyer.js +15 -0
- package/src/objects/MindmapObject.js +76 -0
- package/src/objects/ObjectFactory.js +3 -1
- package/src/services/BoardService.js +127 -31
- package/src/services/GridSnapResolver.js +60 -0
- package/src/services/MiroZoomLevels.js +39 -0
- package/src/services/SettingsApplier.js +0 -4
- package/src/services/ZoomPanController.js +51 -32
- package/src/tools/object-tools/PlacementTool.js +12 -3
- package/src/tools/object-tools/SelectTool.js +11 -1
- package/src/tools/object-tools/placement/GhostController.js +100 -1
- package/src/tools/object-tools/placement/PlacementEventsBridge.js +2 -0
- package/src/tools/object-tools/placement/PlacementInputRouter.js +2 -2
- package/src/tools/object-tools/selection/FileNameInlineEditorController.js +2 -2
- package/src/tools/object-tools/selection/InlineEditorController.js +15 -0
- package/src/tools/object-tools/selection/MindmapInlineEditorController.js +757 -0
- package/src/tools/object-tools/selection/SelectInputRouter.js +20 -1
- package/src/tools/object-tools/selection/SelectToolSetup.js +2 -0
- package/src/tools/object-tools/selection/SelectionStateController.js +7 -0
- package/src/tools/object-tools/selection/TextEditorLifecycleRegistry.js +12 -16
- package/src/ui/ContextMenu.js +6 -6
- package/src/ui/DotGridDebugPanel.js +253 -0
- package/src/ui/HtmlTextLayer.js +1 -1
- package/src/ui/TextPropertiesPanel.js +2 -2
- package/src/ui/handles/GroupSelectionHandlesController.js +4 -1
- package/src/ui/handles/HandlesDomRenderer.js +1485 -14
- package/src/ui/handles/HandlesEventBridge.js +49 -5
- package/src/ui/handles/HandlesInteractionController.js +4 -4
- package/src/ui/mindmap/MindmapConnectionLayer.js +254 -0
- package/src/ui/mindmap/MindmapHtmlTextLayer.js +285 -0
- package/src/ui/mindmap/MindmapLayoutConfig.js +29 -0
- package/src/ui/mindmap/MindmapTextOverlayAdapter.js +144 -0
- package/src/ui/styles/toolbar.css +1 -0
- package/src/ui/styles/workspace.css +100 -0
- package/src/ui/toolbar/ToolbarActionRouter.js +35 -0
- package/src/ui/toolbar/ToolbarPopupsController.js +6 -6
- package/src/ui/toolbar/ToolbarRenderer.js +1 -0
- package/src/ui/toolbar/ToolbarStateController.js +1 -0
- 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 =
|
|
254
|
-
pixiObject.y =
|
|
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 = { ...
|
|
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 =
|
|
298
|
-
pixiObject2.y =
|
|
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 =
|
|
305
|
-
object.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
|
|
87
|
-
const
|
|
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
|
|
package/src/grid/BaseGrid.js
CHANGED
|
@@ -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 объект для рендеринга
|
package/src/grid/CrossGrid.js
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
28
|
-
g.alpha =
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
+
}
|