-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathCamera.ts
More file actions
200 lines (182 loc) · 5.77 KB
/
Copy pathCamera.ts
File metadata and controls
200 lines (182 loc) · 5.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
import { getEffectiveDpr } from "../../utilities/Dpr";
/**
* 2D camera: pan/zoom → column-major mat3 for WebGL2 vertex shaders.
*
* Pure viewport math — no DOM event listeners. Input handling lives
* in GameView, which calls panBy / zoomAtScreen / etc.
*
* Coordinate system:
* World: (0,0) top-left, (mapWidth, mapHeight) bottom-right, +Y down.
* Clip: (-1,-1) bottom-left, (1,1) top-right.
*
* The mat3 maps world → clip:
* sx = zoom * 2 / canvasWidth
* sy = zoom * -2 / canvasHeight (Y flip)
* tx = -offsetX * sx
* ty = -offsetY * sy
*/
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 20;
const DBLCLICK_MIN_ZOOM = 0.7;
const DBLCLICK_MAX_ZOOM = 3;
export class Camera {
offsetX: number;
offsetY: number;
zoom: number;
private mapW: number;
private mapH: number;
private canvasW = 1;
private canvasH = 1;
private mat = new Float32Array(9);
private dirty = true;
/** True until fitMap() has been called with valid canvas dimensions. */
private needsInitialFit = true;
constructor(mapWidth: number, mapHeight: number) {
this.mapW = mapWidth;
this.mapH = mapHeight;
this.offsetX = mapWidth / 2;
this.offsetY = mapHeight / 2;
this.zoom = 1;
}
/** Update canvas pixel dimensions. Triggers initial fitMap on first call. */
resize(cssWidth: number, cssHeight: number): void {
const dpr = getEffectiveDpr();
this.canvasW = Math.round(cssWidth * dpr);
this.canvasH = Math.round(cssHeight * dpr);
if (this.needsInitialFit) {
this.fitMap();
}
this.dirty = true;
}
/** Fit the map into the viewport (~90% fill). */
fitMap(): void {
this.offsetX = this.mapW / 2;
this.offsetY = this.mapH / 2;
const sx = this.canvasW / this.mapW;
const sy = this.canvasH / this.mapH;
this.zoom = Math.min(sx, sy) * 0.9;
this.dirty = true;
this.needsInitialFit = false;
}
/** Center the camera on a bounding box with padding (1.4 ≈ 71% fill). */
focusBBox(
minX: number,
minY: number,
maxX: number,
maxY: number,
padding = 1.4,
): void {
this.offsetX = (minX + maxX + 1) / 2;
this.offsetY = (minY + maxY + 1) / 2;
const bboxW = maxX - minX + 1;
const bboxH = maxY - minY + 1;
const sx = this.canvasW / bboxW;
const sy = this.canvasH / bboxH;
this.zoom = Math.max(
DBLCLICK_MIN_ZOOM,
Math.min(DBLCLICK_MAX_ZOOM, Math.min(sx, sy) / padding),
);
this.clampOffset();
this.dirty = true;
}
/** Set the camera center to a world position. */
panTo(worldX: number, worldY: number): void {
this.offsetX = worldX;
this.offsetY = worldY;
this.clampOffset();
this.dirty = true;
}
/** Shift the camera center by a world-space delta (used for drag panning). */
panBy(dx: number, dy: number): void {
this.offsetX += dx;
this.offsetY += dy;
this.clampOffset();
this.dirty = true;
}
/** Restore camera state, skipping the initial fitMap. */
setCameraState(x: number, y: number, z: number): void {
this.offsetX = x;
this.offsetY = y;
this.zoom = z;
this.needsInitialFit = false;
this.dirty = true;
}
/** Multiply zoom by a factor (centered on current view). */
zoomBy(factor: number): void {
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * factor));
this.clampOffset();
this.dirty = true;
}
/** Set absolute zoom level. */
zoomTo(level: number): void {
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level));
this.clampOffset();
this.dirty = true;
}
/**
* Zoom by a factor while keeping a screen point fixed in world space.
* Used for wheel-zoom: the world position under the cursor stays put.
*/
zoomAtScreen(factor: number, screenX: number, screenY: number): void {
const worldBefore = this.screenToWorld(screenX, screenY);
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * factor));
const worldAfter = this.screenToWorld(screenX, screenY);
this.offsetX += worldBefore.x - worldAfter.x;
this.offsetY += worldBefore.y - worldAfter.y;
this.clampOffset();
this.dirty = true;
}
/** Return the column-major mat3 camera matrix (world → clip). */
getMatrix(): Float32Array {
if (this.dirty) {
const sx = (this.zoom * 2) / this.canvasW;
const sy = (this.zoom * -2) / this.canvasH; // Y flip
const tx = -this.offsetX * sx;
const ty = -this.offsetY * sy;
const m = this.mat;
m[0] = sx;
m[1] = 0;
m[2] = 0;
m[3] = 0;
m[4] = sy;
m[5] = 0;
m[6] = tx;
m[7] = ty;
m[8] = 1;
this.dirty = false;
}
return this.mat;
}
/** Convert screen pixel position to world coordinates. */
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
const dpr = getEffectiveDpr();
const ndcX = ((screenX * dpr) / this.canvasW) * 2 - 1;
const ndcY = -(((screenY * dpr) / this.canvasH) * 2 - 1);
const sx = (this.zoom * 2) / this.canvasW;
const sy = (this.zoom * -2) / this.canvasH;
return {
x: (ndcX - -this.offsetX * sx) / sx,
y: (ndcY - -this.offsetY * sy) / sy,
};
}
/** Convert world coordinates to screen pixel position (CSS pixels). */
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
const dpr = getEffectiveDpr();
return {
x: (this.zoom * (worldX - this.offsetX)) / dpr + this.canvasW / (2 * dpr),
y: (this.zoom * (worldY - this.offsetY)) / dpr + this.canvasH / (2 * dpr),
};
}
private clampOffset(): void {
const halfVpW = this.canvasW / (2 * this.zoom);
const halfVpH = this.canvasH / (2 * this.zoom);
this.offsetX = Math.max(
-halfVpW,
Math.min(this.mapW + halfVpW, this.offsetX),
);
this.offsetY = Math.max(
-halfVpH,
Math.min(this.mapH + halfVpH, this.offsetY),
);
}
}