@uoa-css-lab/duckscatter 1.3.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.
- package/.github/dependabot.yml +42 -0
- package/.github/workflows/ci.yaml +111 -0
- package/.github/workflows/release.yml +55 -0
- package/.prettierrc +11 -0
- package/LICENSE +22 -0
- package/README.md +250 -0
- package/dist/data/data-layer.d.ts +169 -0
- package/dist/data/data-layer.js +402 -0
- package/dist/data/index.d.ts +2 -0
- package/dist/data/index.js +2 -0
- package/dist/data/repository.d.ts +48 -0
- package/dist/data/repository.js +109 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +71 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +58 -0
- package/dist/event-emitter.d.ts +62 -0
- package/dist/event-emitter.js +82 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13 -0
- package/dist/renderer/gpu-layer.d.ts +204 -0
- package/dist/renderer/gpu-layer.js +611 -0
- package/dist/renderer/index.d.ts +3 -0
- package/dist/renderer/index.js +3 -0
- package/dist/renderer/shaders.d.ts +13 -0
- package/dist/renderer/shaders.js +216 -0
- package/dist/renderer/webgpu-context.d.ts +20 -0
- package/dist/renderer/webgpu-context.js +88 -0
- package/dist/scatter-plot.d.ts +210 -0
- package/dist/scatter-plot.js +450 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.js +1 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/label-layer.d.ts +176 -0
- package/dist/ui/label-layer.js +488 -0
- package/docs/image.png +0 -0
- package/eslint.config.js +72 -0
- package/examples/next/README.md +36 -0
- package/examples/next/app/components/ColorExpressionInput.tsx +41 -0
- package/examples/next/app/components/ControlPanel.tsx +30 -0
- package/examples/next/app/components/HoverControlPanel.tsx +69 -0
- package/examples/next/app/components/HoverInfoDisplay.tsx +40 -0
- package/examples/next/app/components/LabelFilterInput.tsx +46 -0
- package/examples/next/app/components/LabelList.tsx +106 -0
- package/examples/next/app/components/PointAlphaSlider.tsx +21 -0
- package/examples/next/app/components/PointLimitSlider.tsx +23 -0
- package/examples/next/app/components/PointList.tsx +105 -0
- package/examples/next/app/components/PointSizeScaleSlider.tsx +22 -0
- package/examples/next/app/components/ScatterPlotCanvas.tsx +150 -0
- package/examples/next/app/components/SearchBox.tsx +46 -0
- package/examples/next/app/components/Slider.tsx +76 -0
- package/examples/next/app/components/StatsDisplay.tsx +15 -0
- package/examples/next/app/components/TimeFilterSlider.tsx +169 -0
- package/examples/next/app/context/ScatterPlotContext.tsx +402 -0
- package/examples/next/app/favicon.ico +0 -0
- package/examples/next/app/globals.css +23 -0
- package/examples/next/app/layout.tsx +35 -0
- package/examples/next/app/page.tsx +15 -0
- package/examples/next/eslint.config.mjs +18 -0
- package/examples/next/next.config.ts +7 -0
- package/examples/next/package-lock.json +6572 -0
- package/examples/next/package.json +27 -0
- package/examples/next/postcss.config.mjs +7 -0
- package/examples/next/scripts/generate_labels.py +167 -0
- package/examples/next/tsconfig.json +34 -0
- package/package.json +43 -0
- package/src/data/data-layer.ts +515 -0
- package/src/data/index.ts +2 -0
- package/src/data/repository.ts +146 -0
- package/src/diagnostics.ts +108 -0
- package/src/errors.ts +69 -0
- package/src/event-emitter.ts +88 -0
- package/src/index.ts +40 -0
- package/src/renderer/gpu-layer.ts +757 -0
- package/src/renderer/index.ts +3 -0
- package/src/renderer/shaders.ts +219 -0
- package/src/renderer/webgpu-context.ts +98 -0
- package/src/scatter-plot.ts +533 -0
- package/src/types.ts +218 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/label-layer.ts +648 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Label,
|
|
3
|
+
LabelFilterLambda,
|
|
4
|
+
PointHoverCallback,
|
|
5
|
+
HoverOutlineOptions,
|
|
6
|
+
LabelIdentifier,
|
|
7
|
+
LabelHoverCallback,
|
|
8
|
+
} from '../types.js';
|
|
9
|
+
import type { DataLayer } from '../data/data-layer.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ラベルレイヤーの初期化オプション
|
|
13
|
+
*/
|
|
14
|
+
export interface LabelLayerOptions {
|
|
15
|
+
/** WebGPUキャンバス(位置決めに使用) */
|
|
16
|
+
canvas: HTMLCanvasElement;
|
|
17
|
+
/** ラベル間の最小距離(ピクセル) */
|
|
18
|
+
minLabelDistance?: number;
|
|
19
|
+
/** ラベルのフォントサイズ(ピクセル) */
|
|
20
|
+
labelFontSize?: number;
|
|
21
|
+
/** ラベルのフィルタリング関数 */
|
|
22
|
+
filterLambda?: LabelFilterLambda;
|
|
23
|
+
/** ラベルクリック時のコールバック */
|
|
24
|
+
onLabelClick?: (label: Label) => void;
|
|
25
|
+
/** ポイントホバー時のコールバック */
|
|
26
|
+
onPointHover?: PointHoverCallback;
|
|
27
|
+
/** ラベルホバー時のコールバック */
|
|
28
|
+
onLabelHover?: LabelHoverCallback;
|
|
29
|
+
/** ホバー時のアウトラインオプション */
|
|
30
|
+
hoverOutlineOptions?: HoverOutlineOptions;
|
|
31
|
+
/** データレイヤー参照 */
|
|
32
|
+
dataLayer?: DataLayer;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ラベル描画用の2Dキャンバスオーバーレイを管理するクラス
|
|
37
|
+
* テキストラベルの描画、衝突検出、座標変換を担当する
|
|
38
|
+
*/
|
|
39
|
+
export class LabelLayer {
|
|
40
|
+
/** WebGPUキャンバス(位置決めに使用) */
|
|
41
|
+
private canvas: HTMLCanvasElement;
|
|
42
|
+
/** ラベル描画用の2Dキャンバス */
|
|
43
|
+
private labelCanvas: HTMLCanvasElement | null = null;
|
|
44
|
+
/** 2Dキャンバスの描画コンテキスト */
|
|
45
|
+
private labelContext: CanvasRenderingContext2D | null = null;
|
|
46
|
+
/** 表示するラベルの配列 */
|
|
47
|
+
private labels: Label[] = [];
|
|
48
|
+
/** ラベル間の最小距離(ピクセル) */
|
|
49
|
+
private readonly minLabelDistance: number = 40;
|
|
50
|
+
/** ラベルのフォントサイズ(ピクセル) */
|
|
51
|
+
private labelFontSize: number = 12;
|
|
52
|
+
/** ラベルフィルタリング関数 */
|
|
53
|
+
private filterLambda?: LabelFilterLambda;
|
|
54
|
+
|
|
55
|
+
/** 現在のズーム倍率 */
|
|
56
|
+
private zoom: number = 1.0;
|
|
57
|
+
/** X方向のパン量 */
|
|
58
|
+
private panX: number = 0.0;
|
|
59
|
+
/** Y方向のパン量 */
|
|
60
|
+
private panY: number = 0.0;
|
|
61
|
+
|
|
62
|
+
/** ラベルクリック時のコールバック */
|
|
63
|
+
private onLabelClick?: (label: Label) => void;
|
|
64
|
+
/** 描画されたラベルのバウンディングボックス配列 */
|
|
65
|
+
private renderedLabelBounds: Array<{
|
|
66
|
+
label: Label;
|
|
67
|
+
x: number;
|
|
68
|
+
y: number;
|
|
69
|
+
width: number;
|
|
70
|
+
height: number;
|
|
71
|
+
}> = [];
|
|
72
|
+
/** 現在ホバー中のラベル */
|
|
73
|
+
private hoveredLabel: Label | null = null;
|
|
74
|
+
/** 通常時のスケール */
|
|
75
|
+
private readonly normalScale = 1.0;
|
|
76
|
+
/** ホバー時のスケール */
|
|
77
|
+
private readonly hoverScale = 1.3;
|
|
78
|
+
/** ヒット検出用のパディング(ピクセル) */
|
|
79
|
+
private readonly hitPadding = 10;
|
|
80
|
+
|
|
81
|
+
/** ポイントホバー時のコールバック */
|
|
82
|
+
private onPointHover?: PointHoverCallback;
|
|
83
|
+
/** ラベルホバー時のコールバック */
|
|
84
|
+
private onLabelHover?: LabelHoverCallback;
|
|
85
|
+
/** 現在ホバー中のポイント */
|
|
86
|
+
private hoveredPoint: { row: any[]; columns: string[] } | null = null;
|
|
87
|
+
/** ホバーアウトラインのオプション */
|
|
88
|
+
private hoverOutlineOptions: HoverOutlineOptions;
|
|
89
|
+
/** データレイヤー参照 */
|
|
90
|
+
private readonly dataLayer: DataLayer | null = null;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* LabelLayerを初期化する
|
|
94
|
+
* @param options 初期化オプション
|
|
95
|
+
*/
|
|
96
|
+
constructor(options: LabelLayerOptions) {
|
|
97
|
+
this.canvas = options.canvas;
|
|
98
|
+
this.minLabelDistance = options.minLabelDistance ?? this.minLabelDistance;
|
|
99
|
+
this.labelFontSize = options.labelFontSize ?? this.labelFontSize;
|
|
100
|
+
this.labels = [];
|
|
101
|
+
this.filterLambda = options.filterLambda;
|
|
102
|
+
this.onLabelClick = options.onLabelClick;
|
|
103
|
+
this.onPointHover = options.onPointHover;
|
|
104
|
+
this.onLabelHover = options.onLabelHover;
|
|
105
|
+
this.dataLayer = options.dataLayer ?? null;
|
|
106
|
+
this.hoverOutlineOptions = {
|
|
107
|
+
enabled: options.hoverOutlineOptions?.enabled ?? true,
|
|
108
|
+
color: options.hoverOutlineOptions?.color ?? 'white',
|
|
109
|
+
width: options.hoverOutlineOptions?.width ?? 2,
|
|
110
|
+
minimumHoverSize: options.hoverOutlineOptions?.minimumHoverSize ?? 10,
|
|
111
|
+
outlinedPointAddition: options.hoverOutlineOptions?.outlinedPointAddition ?? 3,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* ラベルキャンバスオーバーレイを初期化する
|
|
117
|
+
*/
|
|
118
|
+
initialize(): void {
|
|
119
|
+
this.createLabelCanvas();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* ラベル描画用の2Dキャンバスを作成してDOMに追加する
|
|
124
|
+
*/
|
|
125
|
+
private createLabelCanvas(): void {
|
|
126
|
+
this.labelCanvas = document.createElement('canvas');
|
|
127
|
+
this.labelCanvas.width = this.canvas.width;
|
|
128
|
+
this.labelCanvas.height = this.canvas.height;
|
|
129
|
+
this.labelCanvas.style.position = 'absolute';
|
|
130
|
+
this.labelCanvas.style.pointerEvents = 'none';
|
|
131
|
+
this.labelCanvas.style.top = '0';
|
|
132
|
+
this.labelCanvas.style.left = '0';
|
|
133
|
+
this.labelCanvas.style.width = this.canvas.style.width || `${this.canvas.width}px`;
|
|
134
|
+
this.labelCanvas.style.height = this.canvas.style.height || `${this.canvas.height}px`;
|
|
135
|
+
|
|
136
|
+
const parent = this.canvas.parentElement;
|
|
137
|
+
if (parent) {
|
|
138
|
+
if (getComputedStyle(parent).position === 'static') {
|
|
139
|
+
parent.style.position = 'relative';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.labelCanvas.style.top = `${this.canvas.offsetTop}px`;
|
|
143
|
+
this.labelCanvas.style.left = `${this.canvas.offsetLeft}px`;
|
|
144
|
+
|
|
145
|
+
parent.appendChild(this.labelCanvas);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.labelContext = this.labelCanvas.getContext('2d');
|
|
149
|
+
|
|
150
|
+
this.setupEventListeners();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* ビュー変換の状態を更新する
|
|
155
|
+
* @param zoom ズーム倍率
|
|
156
|
+
* @param panX X方向のパン量
|
|
157
|
+
* @param panY Y方向のパン量
|
|
158
|
+
*/
|
|
159
|
+
updateViewTransform(zoom: number, panX: number, panY: number): void {
|
|
160
|
+
this.zoom = zoom;
|
|
161
|
+
this.panX = panX;
|
|
162
|
+
this.panY = panY;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* ラベルを2Dキャンバスに描画する
|
|
167
|
+
*/
|
|
168
|
+
render(): void {
|
|
169
|
+
if (!this.labelContext || !this.labelCanvas) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this.labelContext.clearRect(0, 0, this.labelCanvas.width, this.labelCanvas.height);
|
|
174
|
+
this.renderedLabelBounds = [];
|
|
175
|
+
|
|
176
|
+
if (this.labels.length === 0) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const fontSize = this.labelFontSize;
|
|
181
|
+
this.labelContext.fillStyle = 'white';
|
|
182
|
+
this.labelContext.strokeStyle = 'black';
|
|
183
|
+
this.labelContext.lineWidth = 2;
|
|
184
|
+
this.labelContext.textAlign = 'center';
|
|
185
|
+
this.labelContext.textBaseline = 'middle';
|
|
186
|
+
|
|
187
|
+
const renderedPositions: Array<{ x: number; y: number }> = [];
|
|
188
|
+
|
|
189
|
+
const labelsWithFilter = this.labels.map((label) => ({
|
|
190
|
+
label,
|
|
191
|
+
passedFilter:
|
|
192
|
+
this.filterLambda && label.properties ? this.filterLambda(label.properties) : true,
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
labelsWithFilter.sort((a, b) => {
|
|
196
|
+
if (a.passedFilter !== b.passedFilter) {
|
|
197
|
+
return a.passedFilter ? -1 : 1;
|
|
198
|
+
}
|
|
199
|
+
return 0;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
for (const { label, passedFilter } of labelsWithFilter) {
|
|
203
|
+
const { x: screenX, y: screenY } = this.worldToScreenCoords(label.x, label.y);
|
|
204
|
+
|
|
205
|
+
if (
|
|
206
|
+
screenX >= 0 &&
|
|
207
|
+
screenX <= this.labelCanvas.width &&
|
|
208
|
+
screenY >= 0 &&
|
|
209
|
+
screenY <= this.labelCanvas.height
|
|
210
|
+
) {
|
|
211
|
+
const effectiveMinDistance = this.minLabelDistance * (this.labelFontSize / 12);
|
|
212
|
+
const tooClose = renderedPositions.some((pos) => {
|
|
213
|
+
const dx = pos.x - screenX;
|
|
214
|
+
const dy = pos.y - screenY;
|
|
215
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
216
|
+
return distance < effectiveMinDistance;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!tooClose) {
|
|
220
|
+
const isHovered = this.hoveredLabel === label;
|
|
221
|
+
const scale = isHovered ? this.hoverScale : this.normalScale;
|
|
222
|
+
const scaledFontSize = fontSize * scale;
|
|
223
|
+
|
|
224
|
+
this.labelContext.font = `bold ${scaledFontSize}px sans-serif`;
|
|
225
|
+
|
|
226
|
+
const textMetrics = this.labelContext.measureText(label.text);
|
|
227
|
+
const textWidth = textMetrics.width;
|
|
228
|
+
const textHeight = scaledFontSize;
|
|
229
|
+
|
|
230
|
+
if (passedFilter) {
|
|
231
|
+
this.labelContext.shadowColor = 'rgba(0, 0, 0, 0.4)';
|
|
232
|
+
this.labelContext.shadowBlur = 6;
|
|
233
|
+
this.labelContext.shadowOffsetX = 2;
|
|
234
|
+
this.labelContext.shadowOffsetY = 2;
|
|
235
|
+
|
|
236
|
+
this.labelContext.fillStyle = 'white';
|
|
237
|
+
|
|
238
|
+
if (
|
|
239
|
+
label.properties?.color &&
|
|
240
|
+
Array.isArray(label.properties.color) &&
|
|
241
|
+
label.properties.color.length === 3
|
|
242
|
+
) {
|
|
243
|
+
const [r, g, b] = label.properties.color;
|
|
244
|
+
this.labelContext.strokeStyle = `rgb(${r}, ${g}, ${b})`;
|
|
245
|
+
} else {
|
|
246
|
+
this.labelContext.strokeStyle = 'white';
|
|
247
|
+
}
|
|
248
|
+
this.labelContext.lineWidth = 2;
|
|
249
|
+
} else {
|
|
250
|
+
this.labelContext.shadowColor = 'rgba(0, 0, 0, 0.3)';
|
|
251
|
+
this.labelContext.shadowBlur = 4;
|
|
252
|
+
this.labelContext.shadowOffsetX = 2;
|
|
253
|
+
this.labelContext.shadowOffsetY = 2;
|
|
254
|
+
|
|
255
|
+
this.labelContext.fillStyle = 'rgba(180, 180, 180, 0.6)';
|
|
256
|
+
this.labelContext.strokeStyle = 'rgba(100, 100, 100, 0.6)';
|
|
257
|
+
this.labelContext.lineWidth = 1.5;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this.labelContext.strokeText(label.text, screenX, screenY);
|
|
261
|
+
this.labelContext.fillText(label.text, screenX, screenY);
|
|
262
|
+
|
|
263
|
+
this.labelContext.shadowColor = 'transparent';
|
|
264
|
+
this.labelContext.shadowBlur = 0;
|
|
265
|
+
this.labelContext.shadowOffsetX = 0;
|
|
266
|
+
this.labelContext.shadowOffsetY = 0;
|
|
267
|
+
|
|
268
|
+
this.renderedLabelBounds.push({
|
|
269
|
+
label,
|
|
270
|
+
x: screenX - textWidth / 2,
|
|
271
|
+
y: screenY - textHeight / 2,
|
|
272
|
+
width: textWidth,
|
|
273
|
+
height: textHeight,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
renderedPositions.push({ x: screenX, y: screenY });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
this.renderPointOutline();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* ホバー中のポイントにアウトラインを描画する
|
|
286
|
+
*/
|
|
287
|
+
private renderPointOutline(): void {
|
|
288
|
+
if (!this.labelContext || !this.labelCanvas || !this.hoveredPoint || !this.dataLayer) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!this.hoverOutlineOptions.enabled) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const xIndex = this.hoveredPoint.columns.indexOf('x');
|
|
297
|
+
const yIndex = this.hoveredPoint.columns.indexOf('y');
|
|
298
|
+
|
|
299
|
+
if (xIndex === -1 || yIndex === -1) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const { x: screenX, y: screenY } = this.worldToScreenCoords(
|
|
304
|
+
this.hoveredPoint.row[xIndex],
|
|
305
|
+
this.hoveredPoint.row[yIndex]
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const baseSize = this.dataLayer.getPointSize(this.hoveredPoint.row, this.hoveredPoint.columns);
|
|
309
|
+
const zoomScaledSize = Math.max(
|
|
310
|
+
baseSize * Math.pow(this.zoom, 0.3) + (this.hoverOutlineOptions.outlinedPointAddition ?? 3),
|
|
311
|
+
this.hoverOutlineOptions.minimumHoverSize ?? 10
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const screenRadius = zoomScaledSize;
|
|
315
|
+
|
|
316
|
+
this.labelContext.beginPath();
|
|
317
|
+
this.labelContext.arc(screenX, screenY, screenRadius, 0, Math.PI * 2);
|
|
318
|
+
|
|
319
|
+
const color = this.dataLayer.getPointColor(this.hoveredPoint.row, this.hoveredPoint.columns);
|
|
320
|
+
this.labelContext.fillStyle = `rgba(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}, ${Math.round(color.a * 255)})`;
|
|
321
|
+
this.labelContext.fill();
|
|
322
|
+
|
|
323
|
+
this.labelContext.strokeStyle = this.hoverOutlineOptions.color ?? 'black';
|
|
324
|
+
this.labelContext.lineWidth = this.hoverOutlineOptions.width ?? 2;
|
|
325
|
+
this.labelContext.stroke();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* GeoJSONデータからラベルを読み込む
|
|
330
|
+
* @param geojsonData GeoJSON形式のデータ
|
|
331
|
+
*/
|
|
332
|
+
loadLabels(geojsonData: any): void {
|
|
333
|
+
if (!geojsonData || !geojsonData.features) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const allLabels = geojsonData.features.map((feature: any) => ({
|
|
338
|
+
text: feature.properties?.cluster_label || '',
|
|
339
|
+
x: feature.geometry?.coordinates?.[0] || 0,
|
|
340
|
+
y: feature.geometry?.coordinates?.[1] || 0,
|
|
341
|
+
cluster: feature.properties?.cluster,
|
|
342
|
+
count: feature.properties?.count || 0,
|
|
343
|
+
properties: feature.properties || {},
|
|
344
|
+
}));
|
|
345
|
+
|
|
346
|
+
this.labels = allLabels.sort((a: Label, b: Label) => (b.count || 0) - (a.count || 0));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* ホバーとクリック操作用のイベントリスナーを設定する
|
|
351
|
+
*/
|
|
352
|
+
private setupEventListeners(): void {
|
|
353
|
+
if (!this.labelCanvas) return;
|
|
354
|
+
|
|
355
|
+
const parent = this.labelCanvas.parentElement;
|
|
356
|
+
if (!parent) return;
|
|
357
|
+
|
|
358
|
+
parent.addEventListener('mousemove', async (e: MouseEvent) => {
|
|
359
|
+
if (!this.labelCanvas) return;
|
|
360
|
+
|
|
361
|
+
const rect = this.labelCanvas.getBoundingClientRect();
|
|
362
|
+
const scaleX = this.labelCanvas.width / rect.width;
|
|
363
|
+
const scaleY = this.labelCanvas.height / rect.height;
|
|
364
|
+
const x = (e.clientX - rect.left) * scaleX;
|
|
365
|
+
const y = (e.clientY - rect.top) * scaleY;
|
|
366
|
+
|
|
367
|
+
const labelAtPosition = this.getLabelAtPosition(x, y);
|
|
368
|
+
|
|
369
|
+
let pointHit: { row: any[]; columns: string[] } | null = null;
|
|
370
|
+
if (!labelAtPosition && this.dataLayer) {
|
|
371
|
+
const aspectRatio = this.labelCanvas.width / this.labelCanvas.height;
|
|
372
|
+
pointHit = await this.dataLayer.findNearestPoint(
|
|
373
|
+
x,
|
|
374
|
+
y,
|
|
375
|
+
this.labelCanvas.width,
|
|
376
|
+
this.labelCanvas.height,
|
|
377
|
+
this.zoom,
|
|
378
|
+
this.panX,
|
|
379
|
+
this.panY,
|
|
380
|
+
aspectRatio,
|
|
381
|
+
10
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (labelAtPosition) {
|
|
386
|
+
this.labelCanvas.style.pointerEvents = 'auto';
|
|
387
|
+
this.labelCanvas.style.cursor = 'pointer';
|
|
388
|
+
} else {
|
|
389
|
+
this.labelCanvas.style.pointerEvents = 'none';
|
|
390
|
+
this.labelCanvas.style.cursor = pointHit ? 'pointer' : 'default';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (labelAtPosition !== this.hoveredLabel) {
|
|
394
|
+
this.hoveredLabel = labelAtPosition;
|
|
395
|
+
if (this.onLabelHover) {
|
|
396
|
+
this.onLabelHover(this.hoveredLabel);
|
|
397
|
+
}
|
|
398
|
+
this.render();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (pointHit !== this.hoveredPoint) {
|
|
402
|
+
this.hoveredPoint = pointHit;
|
|
403
|
+
|
|
404
|
+
if (this.onPointHover) {
|
|
405
|
+
this.onPointHover(this.hoveredPoint);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.render();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
this.labelCanvas.addEventListener('click', (e: MouseEvent) => {
|
|
413
|
+
const rect = this.labelCanvas!.getBoundingClientRect();
|
|
414
|
+
const scaleX = this.labelCanvas!.width / rect.width;
|
|
415
|
+
const scaleY = this.labelCanvas!.height / rect.height;
|
|
416
|
+
const x = (e.clientX - rect.left) * scaleX;
|
|
417
|
+
const y = (e.clientY - rect.top) * scaleY;
|
|
418
|
+
|
|
419
|
+
const labelAtPosition = this.getLabelAtPosition(x, y);
|
|
420
|
+
|
|
421
|
+
if (labelAtPosition && this.onLabelClick) {
|
|
422
|
+
this.onLabelClick(labelAtPosition);
|
|
423
|
+
e.stopPropagation();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
this.labelCanvas.addEventListener(
|
|
428
|
+
'wheel',
|
|
429
|
+
(e: WheelEvent) => {
|
|
430
|
+
const newEvent = new WheelEvent('wheel', e);
|
|
431
|
+
this.canvas.dispatchEvent(newEvent);
|
|
432
|
+
},
|
|
433
|
+
{ passive: false }
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
this.labelCanvas.addEventListener('mousedown', (e: MouseEvent) => {
|
|
437
|
+
const newEvent = new MouseEvent('mousedown', e);
|
|
438
|
+
this.canvas.dispatchEvent(newEvent);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
this.labelCanvas.addEventListener('mousemove', (e: MouseEvent) => {
|
|
442
|
+
const newEvent = new MouseEvent('mousemove', e);
|
|
443
|
+
this.canvas.dispatchEvent(newEvent);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
this.labelCanvas.addEventListener('mouseup', (e: MouseEvent) => {
|
|
447
|
+
const newEvent = new MouseEvent('mouseup', e);
|
|
448
|
+
this.canvas.dispatchEvent(newEvent);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
this.labelCanvas.addEventListener('mouseleave', (e: MouseEvent) => {
|
|
452
|
+
const newEvent = new MouseEvent('mouseleave', e);
|
|
453
|
+
this.canvas.dispatchEvent(newEvent);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
parent.addEventListener('mouseleave', () => {
|
|
457
|
+
const hadLabel = this.hoveredLabel !== null;
|
|
458
|
+
const hadPoint = this.hoveredPoint !== null;
|
|
459
|
+
|
|
460
|
+
this.hoveredLabel = null;
|
|
461
|
+
this.hoveredPoint = null;
|
|
462
|
+
|
|
463
|
+
if (hadLabel && this.onLabelHover) {
|
|
464
|
+
this.onLabelHover(null);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (hadPoint && this.onPointHover) {
|
|
468
|
+
this.onPointHover(null);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (this.labelCanvas) {
|
|
472
|
+
this.labelCanvas.style.pointerEvents = 'none';
|
|
473
|
+
this.labelCanvas.style.cursor = 'default';
|
|
474
|
+
}
|
|
475
|
+
this.render();
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* 指定された座標にあるラベルを検索する
|
|
481
|
+
* @param x X座標(ピクセル)
|
|
482
|
+
* @param y Y座標(ピクセル)
|
|
483
|
+
* @returns 見つかったラベル、またはnull
|
|
484
|
+
*/
|
|
485
|
+
private getLabelAtPosition(x: number, y: number): Label | null {
|
|
486
|
+
for (const bound of this.renderedLabelBounds) {
|
|
487
|
+
if (
|
|
488
|
+
x >= bound.x - this.hitPadding &&
|
|
489
|
+
x <= bound.x + bound.width + this.hitPadding &&
|
|
490
|
+
y >= bound.y - this.hitPadding &&
|
|
491
|
+
y <= bound.y + bound.height + this.hitPadding
|
|
492
|
+
) {
|
|
493
|
+
return bound.label;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* ラベルキャンバスのサイズを変更する
|
|
501
|
+
* @param width 新しい幅
|
|
502
|
+
* @param height 新しい高さ
|
|
503
|
+
*/
|
|
504
|
+
resize(width: number, height: number): void {
|
|
505
|
+
if (this.labelCanvas) {
|
|
506
|
+
this.labelCanvas.width = width;
|
|
507
|
+
this.labelCanvas.height = height;
|
|
508
|
+
this.labelCanvas.style.width = this.canvas.style.width;
|
|
509
|
+
this.labelCanvas.style.height = this.canvas.style.height;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* ラベルレイヤーのオプションを更新する
|
|
515
|
+
* @param options 更新するオプション
|
|
516
|
+
*/
|
|
517
|
+
updateOptions(options: Partial<LabelLayerOptions>): void {
|
|
518
|
+
if (options.labelFontSize !== undefined) {
|
|
519
|
+
this.labelFontSize = options.labelFontSize;
|
|
520
|
+
}
|
|
521
|
+
if (options.filterLambda !== undefined) {
|
|
522
|
+
this.filterLambda = options.filterLambda;
|
|
523
|
+
}
|
|
524
|
+
if (options.onLabelClick !== undefined) {
|
|
525
|
+
this.onLabelClick = options.onLabelClick;
|
|
526
|
+
}
|
|
527
|
+
if (options.onPointHover !== undefined) {
|
|
528
|
+
this.onPointHover = options.onPointHover;
|
|
529
|
+
}
|
|
530
|
+
if (options.onLabelHover !== undefined) {
|
|
531
|
+
this.onLabelHover = options.onLabelHover;
|
|
532
|
+
}
|
|
533
|
+
if (options.hoverOutlineOptions !== undefined) {
|
|
534
|
+
this.hoverOutlineOptions = {
|
|
535
|
+
enabled: options.hoverOutlineOptions.enabled ?? this.hoverOutlineOptions.enabled,
|
|
536
|
+
color: options.hoverOutlineOptions.color ?? this.hoverOutlineOptions.color,
|
|
537
|
+
width: options.hoverOutlineOptions.width ?? this.hoverOutlineOptions.width,
|
|
538
|
+
minimumHoverSize:
|
|
539
|
+
options.hoverOutlineOptions.minimumHoverSize ?? this.hoverOutlineOptions.minimumHoverSize,
|
|
540
|
+
outlinedPointAddition:
|
|
541
|
+
options.hoverOutlineOptions.outlinedPointAddition ??
|
|
542
|
+
this.hoverOutlineOptions.outlinedPointAddition,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* 現在のラベル配列を取得する
|
|
549
|
+
* @returns ラベルの配列
|
|
550
|
+
*/
|
|
551
|
+
getLabels(): Label[] {
|
|
552
|
+
return this.labels;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* プログラム的にホバー中のポイントを設定する
|
|
557
|
+
* @param data ホバーするポイントデータ、またはnullでクリア
|
|
558
|
+
*/
|
|
559
|
+
setHoveredPoint(data: { row: any[]; columns: string[] } | null): void {
|
|
560
|
+
if (data === this.hoveredPoint) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
this.hoveredPoint = data;
|
|
564
|
+
|
|
565
|
+
if (this.onPointHover) {
|
|
566
|
+
this.onPointHover(this.hoveredPoint);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
this.render();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* プログラム的にホバー中のラベルを設定する
|
|
574
|
+
* @param label ホバーするラベル、またはnullでクリア
|
|
575
|
+
*/
|
|
576
|
+
setHoveredLabel(label: Label | null): void {
|
|
577
|
+
if (label === this.hoveredLabel) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
this.hoveredLabel = label;
|
|
581
|
+
|
|
582
|
+
if (this.onLabelHover) {
|
|
583
|
+
this.onLabelHover(this.hoveredLabel);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
this.render();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 識別子でラベルを検索する
|
|
591
|
+
* @param identifier ラベル識別子(textまたはcluster)
|
|
592
|
+
* @returns 見つかったラベル、またはnull
|
|
593
|
+
*/
|
|
594
|
+
findLabel(identifier: LabelIdentifier): Label | null {
|
|
595
|
+
for (const label of this.labels) {
|
|
596
|
+
if (identifier.text !== undefined && label.text === identifier.text) {
|
|
597
|
+
return label;
|
|
598
|
+
}
|
|
599
|
+
if (identifier.cluster !== undefined && label.cluster === identifier.cluster) {
|
|
600
|
+
return label;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* 現在ホバー中のポイントを取得する
|
|
608
|
+
* @returns ホバー中のポイント、またはnull
|
|
609
|
+
*/
|
|
610
|
+
getHoveredPoint(): { row: any[]; columns: string[] } | null {
|
|
611
|
+
return this.hoveredPoint;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* 現在ホバー中のラベルを取得する
|
|
616
|
+
* @returns ホバー中のラベル、またはnull
|
|
617
|
+
*/
|
|
618
|
+
getHoveredLabel(): Label | null {
|
|
619
|
+
return this.hoveredLabel;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* ワールド座標をスクリーン座標に変換する
|
|
624
|
+
* @param worldX ワールドX座標
|
|
625
|
+
* @param worldY ワールドY座標
|
|
626
|
+
* @returns スクリーン座標
|
|
627
|
+
*/
|
|
628
|
+
private worldToScreenCoords(worldX: number, worldY: number): { x: number; y: number } {
|
|
629
|
+
if (!this.labelCanvas) {
|
|
630
|
+
return { x: 0, y: 0 };
|
|
631
|
+
}
|
|
632
|
+
const aspectRatio = this.labelCanvas.width / this.labelCanvas.height;
|
|
633
|
+
const clipX = worldX * (this.zoom / aspectRatio) + this.panX;
|
|
634
|
+
const clipY = worldY * this.zoom + this.panY;
|
|
635
|
+
const screenX = (clipX + 1) * 0.5 * this.labelCanvas.width;
|
|
636
|
+
const screenY = (1 - clipY) * 0.5 * this.labelCanvas.height;
|
|
637
|
+
return { x: screenX, y: screenY };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* リソースを解放してレイヤーを破棄する
|
|
642
|
+
*/
|
|
643
|
+
destroy(): void {
|
|
644
|
+
if (this.labelCanvas && this.labelCanvas.parentElement) {
|
|
645
|
+
this.labelCanvas.parentElement.removeChild(this.labelCanvas);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ES2020",
|
|
5
|
+
"lib": ["ES2020", "DOM"],
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"moduleResolution": "node",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"types": ["@webgpu/types"]
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist", "examples"]
|
|
19
|
+
}
|