auto: 2026-08-29 16:13 (EOMSANGDON-HOME)
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import { CANVAS_INPUT_FIELD_FONT_SIZE } from '../App.consts.ts';
|
||||
|
||||
export interface DrawController {
|
||||
getCanvasSize(): Point;
|
||||
getScreenScale(): number;
|
||||
getScreenOffset(): Point;
|
||||
|
||||
worldToTarget(worldCoordinate: Point): Point;
|
||||
worldsToTargets(worldCoordinates: Point[]): Point[];
|
||||
targetToWorld(screenCoordinate: Point): Point;
|
||||
targetsToWorlds(screenCoordinates: Point[]): Point[];
|
||||
|
||||
setLineStyles(
|
||||
isHighlighted: boolean,
|
||||
isSelected: boolean,
|
||||
color: string,
|
||||
lineWidth: number,
|
||||
dash?: number[],
|
||||
): void;
|
||||
setFillStyles(fillColor: string): void;
|
||||
clear(): void;
|
||||
drawLine(startPoint: Point, endPoint: Point): void;
|
||||
drawArc(
|
||||
centerPoint: Point,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockwise: boolean,
|
||||
): void;
|
||||
drawText(
|
||||
label: string,
|
||||
basePoint: Point,
|
||||
options: Partial<{
|
||||
textDirection?: Vector;
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}>,
|
||||
): void;
|
||||
drawImage(
|
||||
imageElement: HTMLImageElement,
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number,
|
||||
): void;
|
||||
fillPolygon(...points: Point[]): void;
|
||||
}
|
||||
|
||||
export const DEFAULT_TEXT_OPTIONS = {
|
||||
textDirection: new Vector(1, 0),
|
||||
textAlign: 'center' as const,
|
||||
textColor: '#FFF',
|
||||
fontSize: CANVAS_INPUT_FIELD_FONT_SIZE,
|
||||
fontFamily: 'sans-serif',
|
||||
};
|
||||
@@ -0,0 +1,655 @@
|
||||
import { Point, type Vector } from '@flatten-js/core';
|
||||
import { CANVAS_BACKGROUND_COLOR, MOUSE_ZOOM_MULTIPLIER } from '../App.consts';
|
||||
import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts';
|
||||
import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts';
|
||||
import { mapNumberRange } from '../helpers/map-number-range.ts';
|
||||
import { StateVariable } from '../helpers/undo-stack.ts';
|
||||
import { getEntities, getGridEnabled, triggerReactUpdate } from '../state.ts';
|
||||
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
|
||||
|
||||
/**
|
||||
* Screen coordinate system:
|
||||
* 0, 0 X
|
||||
* +---------->
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* Y v
|
||||
*
|
||||
*
|
||||
* World coordinate system:
|
||||
* Y ^
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* +---------->
|
||||
* 0, 0 X
|
||||
*
|
||||
* To convert between the 2 coordinate systems, you need the screenOffset and screenScale
|
||||
*/
|
||||
// Batch-mode stroke decimation: skip chain segments shorter than this (screen px)
|
||||
const BATCH_LOD_PX = 0.5;
|
||||
// Batch-mode text smaller than this (screen px) is unreadable — skip drawing it
|
||||
const BATCH_MIN_TEXT_PX = 2;
|
||||
|
||||
export class ScreenCanvasDrawController implements DrawController {
|
||||
private screenOffset: Point = new Point(0, 0);
|
||||
private screenScale = 1;
|
||||
private screenMouseLocation: Point;
|
||||
private canvasSize: Point = new Point(100, 100);
|
||||
|
||||
// Style-run batching (static scene rendering): consecutive stroke calls
|
||||
// with the same style are collected into one Path2D and stroked once.
|
||||
private batching = false;
|
||||
private batchPath: Path2D | null = null;
|
||||
private batchKey: string | null = null;
|
||||
private batchStyle: { color: string; lineWidth: number; dash: number[] } | null = null;
|
||||
private batchLastX = Number.NaN;
|
||||
private batchLastY = Number.NaN;
|
||||
|
||||
constructor(private context: CanvasRenderingContext2D) {
|
||||
this.screenMouseLocation = new Point(this.canvasSize.x / 2, this.canvasSize.y / 2);
|
||||
this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down
|
||||
}
|
||||
|
||||
public getCanvasSize() {
|
||||
return this.canvasSize;
|
||||
}
|
||||
|
||||
public setCanvasSize(newCanvasSize: Point) {
|
||||
this.canvasSize = newCanvasSize;
|
||||
}
|
||||
|
||||
public getScreenScale() {
|
||||
return this.screenScale;
|
||||
}
|
||||
|
||||
public setScreenScale(newScreenScale: number) {
|
||||
console.log(`set screen scale: ${newScreenScale}`);
|
||||
this.screenScale = newScreenScale;
|
||||
triggerReactUpdate(StateVariable.screenZoom);
|
||||
}
|
||||
|
||||
public getScreenOffset() {
|
||||
return this.screenOffset;
|
||||
}
|
||||
|
||||
public setScreenOffset(newScreenOffset: Point) {
|
||||
this.screenOffset = newScreenOffset;
|
||||
triggerReactUpdate(StateVariable.screenOffset);
|
||||
}
|
||||
|
||||
public setScreenMouseLocation(newScreenMouseLocation: Point): void {
|
||||
this.screenMouseLocation = newScreenMouseLocation;
|
||||
triggerReactUpdate(StateVariable.screenMouseLocation);
|
||||
}
|
||||
|
||||
public getWorldMouseLocation(): Point {
|
||||
return this.targetToWorld(this.screenMouseLocation);
|
||||
}
|
||||
|
||||
public getScreenMouseLocation(): Point {
|
||||
return this.screenMouseLocation;
|
||||
}
|
||||
|
||||
public panScreen(screenOffsetX: number, screenOffsetY: number) {
|
||||
this.screenOffset = new Point(
|
||||
this.screenOffset.x - screenOffsetX / this.screenScale,
|
||||
this.screenOffset.y - screenOffsetY / this.screenScale
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes the deltaY from the mouse wheel event and zooms the screen in or out
|
||||
* The location of the mouse in world space is preserved
|
||||
* @param deltaY
|
||||
*/
|
||||
public zoomScreen(deltaY: number) {
|
||||
const worldMouseLocationBeforeZoom = this.getWorldMouseLocation();
|
||||
const oldScreenScale = this.getScreenScale();
|
||||
|
||||
const newScreenScale =
|
||||
oldScreenScale * (1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY)));
|
||||
this.setScreenScale(newScreenScale);
|
||||
|
||||
// now get the location of the cursor in world space again
|
||||
// It will have changed because the scale has changed,
|
||||
// but we can offset our world now to fix the zoom location in screen space,
|
||||
// because we know how much it changed laterally between the two spatial scales.
|
||||
const worldMouseLocationAfterZoom = this.getWorldMouseLocation();
|
||||
|
||||
const offsetAdjustment = new Point(
|
||||
worldMouseLocationBeforeZoom.x - worldMouseLocationAfterZoom.x,
|
||||
worldMouseLocationBeforeZoom.y - worldMouseLocationAfterZoom.y
|
||||
);
|
||||
|
||||
// Adjust the screen offset to maintain the cursor position
|
||||
this.screenOffset = new Point(
|
||||
this.screenOffset.x + offsetAdjustment.x,
|
||||
this.screenOffset.y + offsetAdjustment.y
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 도면(모든 엔티티)을 화면 중심에 여백을 두고 배치한다.
|
||||
* 가로/세로 중 더 제약이 큰 축에 맞춰 배율을 정하고, 도면 중심이 화면 중심에
|
||||
* 오도록 screenOffset(월드 좌표)을 역산한다. (기존 구현은 화면 픽셀 여백값을
|
||||
* 월드 좌표 offset에 그대로 대입해 중심 배치가 어긋나는 문제가 있었다.)
|
||||
*/
|
||||
public zoomToFitScreen() {
|
||||
const entities = getEntities();
|
||||
if (!entities.length) return;
|
||||
const boundingBox = getBoundingBoxOfMultipleEntities(entities);
|
||||
const boundingWidth = boundingBox.maxX - boundingBox.minX;
|
||||
const boundingHeight = boundingBox.maxY - boundingBox.minY;
|
||||
const canvasSize = this.getCanvasSize();
|
||||
|
||||
// 10% 여백을 남기고 두 축 중 더 빡빡한 쪽에 맞춘다 (종횡비 유지)
|
||||
const FIT_MARGIN = 0.9;
|
||||
const scaleX =
|
||||
boundingWidth > 0 ? (canvasSize.x * FIT_MARGIN) / boundingWidth : Number.POSITIVE_INFINITY;
|
||||
const scaleY =
|
||||
boundingHeight > 0 ? (canvasSize.y * FIT_MARGIN) / boundingHeight : Number.POSITIVE_INFINITY;
|
||||
let zoomLevel = Math.min(scaleX, scaleY);
|
||||
if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) zoomLevel = 1;
|
||||
this.setScreenScale(zoomLevel);
|
||||
|
||||
// screen = (world - offset) * zoom 이므로, 도면 중심을 화면 중심에 맞추려면
|
||||
// offset = worldCenter - (화면 절반 픽셀) / zoom
|
||||
const worldCenterX = (boundingBox.minX + boundingBox.maxX) / 2;
|
||||
const worldCenterY = (boundingBox.minY + boundingBox.maxY) / 2;
|
||||
this.setScreenOffset(
|
||||
new Point(
|
||||
worldCenterX - canvasSize.x / 2 / zoomLevel,
|
||||
worldCenterY - canvasSize.y / 2 / zoomLevel
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates from World Space --> Screen Space
|
||||
*/
|
||||
public worldToTarget(worldCoordinate: Point): Point {
|
||||
return new Point(
|
||||
mapNumberRange(
|
||||
worldCoordinate.x,
|
||||
this.screenOffset.x,
|
||||
this.screenOffset.x + this.canvasSize.x / this.screenScale,
|
||||
0,
|
||||
this.canvasSize.x
|
||||
),
|
||||
mapNumberRange(
|
||||
worldCoordinate.y,
|
||||
this.screenOffset.y,
|
||||
this.screenOffset.y + this.canvasSize.y / this.screenScale,
|
||||
0,
|
||||
this.canvasSize.y
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public worldsToTargets(worldCoordinates: Point[]): Point[] {
|
||||
return worldCoordinates.map(this.worldToTarget.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates from Screen Space --> World Space
|
||||
* (0, 0) (1920, 0)
|
||||
*
|
||||
* (0, 1080) (1920, 1080)
|
||||
*
|
||||
* convert to
|
||||
*
|
||||
* (0, 1080) (1920, 1080)
|
||||
*
|
||||
* (0, 0) (1920, 0)
|
||||
*/
|
||||
public targetToWorld(screenCoordinate: Point): Point {
|
||||
// map the screen coordinate to the world coordinate based on this.getScreenOffset() and the this.getScreenScale()
|
||||
return new Point(
|
||||
mapNumberRange(
|
||||
screenCoordinate.x,
|
||||
0,
|
||||
this.canvasSize.x,
|
||||
this.screenOffset.x,
|
||||
this.screenOffset.x + this.canvasSize.x / this.screenScale
|
||||
),
|
||||
mapNumberRange(
|
||||
screenCoordinate.y,
|
||||
0,
|
||||
this.canvasSize.y,
|
||||
this.screenOffset.y,
|
||||
this.screenOffset.y + this.canvasSize.y / this.screenScale
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public targetsToWorlds(screenCoordinates: Point[]): Point[] {
|
||||
return screenCoordinates.map(this.targetToWorld.bind(this));
|
||||
}
|
||||
|
||||
public setLineStyles(
|
||||
isHighlighted: boolean,
|
||||
isSelected: boolean,
|
||||
color: string,
|
||||
lineWidth: number,
|
||||
dash: number[] = []
|
||||
) {
|
||||
if (this.batching) {
|
||||
const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth;
|
||||
const effectiveDash = isSelected ? [5, 5] : dash;
|
||||
const key = `${color}|${effectiveWidth}|${effectiveDash.join(',')}`;
|
||||
if (key !== this.batchKey) {
|
||||
this.flushBatch();
|
||||
this.batchKey = key;
|
||||
this.batchStyle = { color, lineWidth: effectiveWidth, dash: effectiveDash };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.strokeStyle = color;
|
||||
this.context.lineWidth = lineWidth;
|
||||
this.context.setLineDash(dash);
|
||||
|
||||
if (isHighlighted) {
|
||||
this.context.lineWidth = lineWidth + 1;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
this.context.setLineDash([5, 5]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start style-run batching: consecutive stroke calls sharing a style are
|
||||
* accumulated into a single Path2D and stroked once (with sub-pixel
|
||||
* segment decimation). Used while rendering the static scene cache.
|
||||
*/
|
||||
public beginBatch() {
|
||||
this.flushBatch();
|
||||
this.batching = true;
|
||||
this.batchKey = null;
|
||||
this.batchStyle = null;
|
||||
}
|
||||
|
||||
public endBatch() {
|
||||
this.flushBatch();
|
||||
this.batching = false;
|
||||
this.batchKey = null;
|
||||
this.batchStyle = null;
|
||||
}
|
||||
|
||||
private flushBatch() {
|
||||
if (this.batchPath && this.batchStyle) {
|
||||
this.context.strokeStyle = this.batchStyle.color;
|
||||
this.context.lineWidth = this.batchStyle.lineWidth;
|
||||
this.context.setLineDash(this.batchStyle.dash);
|
||||
// Round caps/joins replace the per-segment endpoint dots drawn in
|
||||
// the unbatched path (see _drawRoundedEndpoint)
|
||||
this.context.lineCap = 'round';
|
||||
this.context.lineJoin = 'round';
|
||||
this.context.stroke(this.batchPath);
|
||||
this.context.lineCap = 'butt';
|
||||
this.context.lineJoin = 'miter';
|
||||
}
|
||||
this.batchPath = null;
|
||||
this.batchLastX = Number.NaN;
|
||||
this.batchLastY = Number.NaN;
|
||||
}
|
||||
|
||||
public setFillStyles(fillColor: string) {
|
||||
this.context.fillStyle = fillColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily redirect all draw calls to another 2d context (eg an
|
||||
* offscreen canvas used as static scene cache), reusing the current
|
||||
* offset/scale/canvasSize without touching state or react triggers.
|
||||
*/
|
||||
public withContext(temporaryContext: CanvasRenderingContext2D, renderFunction: () => void) {
|
||||
const originalContext = this.context;
|
||||
this.context = temporaryContext;
|
||||
try {
|
||||
renderFunction();
|
||||
} finally {
|
||||
this.context = originalContext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blit a pre-rendered bitmap (static scene cache) onto the canvas at a
|
||||
* pixel offset. Used while panning to avoid re-stroking every entity.
|
||||
*/
|
||||
public blitImage(source: CanvasImageSource, dx: number, dy: number) {
|
||||
this.context.drawImage(source, dx, dy);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
if (this.canvasSize === null) return;
|
||||
|
||||
if (!this.context) return;
|
||||
if (this.batching) this.flushBatch();
|
||||
|
||||
this.context.fillStyle = CANVAS_BACKGROUND_COLOR;
|
||||
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
|
||||
if (getGridEnabled()) {
|
||||
this.context.strokeStyle = '#242b35';
|
||||
this.context.lineWidth = 1;
|
||||
this.context.setLineDash([]);
|
||||
this.context.beginPath();
|
||||
for (let x = 0.5; x < this.canvasSize.x; x += 24) {
|
||||
this.context.moveTo(x, 0);
|
||||
this.context.lineTo(x, this.canvasSize.y);
|
||||
}
|
||||
for (let y = 0.5; y < this.canvasSize.y; y += 24) {
|
||||
this.context.moveTo(0, y);
|
||||
this.context.lineTo(this.canvasSize.x, y);
|
||||
}
|
||||
this.context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a line from startPoint to endPoint and auto converts to screen space first
|
||||
* @param worldStartPoint
|
||||
* @param worldEndPoint
|
||||
*/
|
||||
public drawLine(worldStartPoint: Point, worldEndPoint: Point): void {
|
||||
const [screenStartPoint, screenEndPoint] = this.worldsToTargets([
|
||||
worldStartPoint,
|
||||
worldEndPoint,
|
||||
]);
|
||||
|
||||
this.drawLineScreen(screenStartPoint, screenEndPoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Needs to be public to draw UI that is zoom independent, like snap point indicators
|
||||
* @param screenStartPoint
|
||||
* @param screenEndPoint
|
||||
*/
|
||||
public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void {
|
||||
if (this.batching) {
|
||||
const startX = screenStartPoint.x;
|
||||
const startY = this.canvasSize.y - screenStartPoint.y;
|
||||
const endX = screenEndPoint.x;
|
||||
const endY = this.canvasSize.y - screenEndPoint.y;
|
||||
if (!this.batchPath) this.batchPath = new Path2D();
|
||||
// Chain break: start is not where the previous segment ended
|
||||
const chainBroken =
|
||||
Math.abs(startX - this.batchLastX) > BATCH_LOD_PX ||
|
||||
Math.abs(startY - this.batchLastY) > BATCH_LOD_PX;
|
||||
if (chainBroken || Number.isNaN(this.batchLastX)) {
|
||||
this.batchPath.moveTo(startX, startY);
|
||||
this.batchLastX = startX;
|
||||
this.batchLastY = startY;
|
||||
}
|
||||
const isTinyStep =
|
||||
Math.abs(endX - this.batchLastX) < BATCH_LOD_PX &&
|
||||
Math.abs(endY - this.batchLastY) < BATCH_LOD_PX;
|
||||
// Decimate sub-pixel steps inside a chain; isolated segments always draw
|
||||
if (!isTinyStep || chainBroken) {
|
||||
this.batchPath.lineTo(endX, endY);
|
||||
this.batchLastX = endX;
|
||||
this.batchLastY = endY;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.beginPath();
|
||||
this.context.moveTo(screenStartPoint.x, this.canvasSize.y - screenStartPoint.y);
|
||||
this.context.lineTo(screenEndPoint.x, this.canvasSize.y - screenEndPoint.y);
|
||||
this.context.stroke();
|
||||
|
||||
const lineWidth = this.context.lineWidth;
|
||||
const style = this.context.strokeStyle as string;
|
||||
this._drawRoundedEndpoint(screenStartPoint, lineWidth, style);
|
||||
this._drawRoundedEndpoint(screenEndPoint, lineWidth, style);
|
||||
}
|
||||
|
||||
private _drawRoundedEndpoint(screenPoint: Point, lineWidth: number, style: string): void {
|
||||
this.context.fillStyle = style;
|
||||
this.context.beginPath();
|
||||
this.context.arc(
|
||||
screenPoint.x,
|
||||
this.canvasSize.y - screenPoint.y,
|
||||
lineWidth / 2,
|
||||
0,
|
||||
2 * Math.PI
|
||||
);
|
||||
this.context.fill();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI
|
||||
* @param centerPoint
|
||||
* @param radius
|
||||
* @param startAngle
|
||||
* @param endAngle
|
||||
* @param counterClockWise
|
||||
*/
|
||||
public drawArc(
|
||||
centerPoint: Point,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockWise: boolean
|
||||
) {
|
||||
const screenCenterPoint = this.worldToTarget(centerPoint);
|
||||
const screenRadius = radius * this.screenScale;
|
||||
// Flip angles over the x-axis, because we go from world to screen coordinates which flips the y-axis direction
|
||||
this.drawArcScreen(screenCenterPoint, screenRadius, -startAngle, -endAngle, counterClockWise);
|
||||
}
|
||||
|
||||
public drawArcScreen(
|
||||
screenCenterPoint: Point,
|
||||
screenRadius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockWise: boolean
|
||||
) {
|
||||
if (this.batching) {
|
||||
if (screenRadius < BATCH_LOD_PX) return; // invisible at this zoom
|
||||
if (!this.batchPath) this.batchPath = new Path2D();
|
||||
const centerX = screenCenterPoint.x;
|
||||
const centerY = this.canvasSize.y - screenCenterPoint.y;
|
||||
this.batchPath.moveTo(
|
||||
centerX + screenRadius * Math.cos(startAngle),
|
||||
centerY + screenRadius * Math.sin(startAngle)
|
||||
);
|
||||
this.batchPath.arc(centerX, centerY, screenRadius, startAngle, endAngle, counterClockWise);
|
||||
// Arc end becomes the new chain tail
|
||||
this.batchLastX = centerX + screenRadius * Math.cos(endAngle);
|
||||
this.batchLastY = centerY + screenRadius * Math.sin(endAngle);
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.beginPath();
|
||||
this.context.arc(
|
||||
screenCenterPoint.x,
|
||||
this.canvasSize.y - screenCenterPoint.y,
|
||||
screenRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
counterClockWise
|
||||
);
|
||||
this.context.stroke();
|
||||
|
||||
const lineWidth = this.context.lineWidth;
|
||||
const style = this.context.strokeStyle as string;
|
||||
|
||||
// Calculate arc endpoints
|
||||
const startScreenX = screenCenterPoint.x + screenRadius * Math.cos(startAngle);
|
||||
// Y is inverted in canvas, but also for the arc angles, so we subtract from canvasSize.y and then add sin
|
||||
const startScreenY =
|
||||
this.canvasSize.y - screenCenterPoint.y + screenRadius * Math.sin(startAngle);
|
||||
const endScreenX = screenCenterPoint.x + screenRadius * Math.cos(endAngle);
|
||||
const endScreenY = this.canvasSize.y - screenCenterPoint.y + screenRadius * Math.sin(endAngle);
|
||||
|
||||
// Convert back to Point objects, note that _drawRoundedEndpoint expects y to be from top of canvas
|
||||
const arcStartPoint = new Point(startScreenX, this.canvasSize.y - startScreenY);
|
||||
const arcEndPoint = new Point(endScreenX, this.canvasSize.y - endScreenY);
|
||||
|
||||
this._drawRoundedEndpoint(arcStartPoint, lineWidth, style);
|
||||
this._drawRoundedEndpoint(arcEndPoint, lineWidth, style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw some text at the base location
|
||||
* The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text * @param label
|
||||
* @param label
|
||||
* @param basePoint
|
||||
* @param options
|
||||
*/
|
||||
public drawText(
|
||||
label: string,
|
||||
basePoint: Point,
|
||||
options: Partial<{
|
||||
textDirection?: Vector;
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}> = {}
|
||||
): void {
|
||||
const screenBasePoint = this.worldToTarget(basePoint);
|
||||
this.drawTextScreen(label, screenBasePoint, {
|
||||
...options,
|
||||
fontSize: options.fontSize ? options.fontSize * this.screenScale : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw some text at the base location
|
||||
* The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text
|
||||
* @param label
|
||||
* @param basePoint
|
||||
* @param options
|
||||
*/
|
||||
public drawTextScreen(
|
||||
label: string,
|
||||
basePoint: Point,
|
||||
options: Partial<{
|
||||
textDirection?: Vector;
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
}> = {}
|
||||
): void {
|
||||
const opts = {
|
||||
...DEFAULT_TEXT_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
if (this.batching) {
|
||||
if (opts.fontSize < BATCH_MIN_TEXT_PX) return; // unreadable at this zoom
|
||||
this.flushBatch(); // keep draw order: strokes so far go under this text
|
||||
}
|
||||
this.context.save();
|
||||
this.context.translate(basePoint.x, this.canvasSize.y - basePoint.y);
|
||||
const angle = getAngleWithXAxis(
|
||||
new Point(0, 0),
|
||||
new Point(opts.textDirection.x, -opts.textDirection.y)
|
||||
);
|
||||
this.context.rotate(angle);
|
||||
this.context.font = `${opts.fontSize}px ${opts.fontFamily}`;
|
||||
this.context.textAlign = opts.textAlign;
|
||||
this.context.fillStyle = opts.textColor;
|
||||
this.context.textBaseline = 'middle';
|
||||
this.context.fillText(label, 0, 0);
|
||||
this.context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an image to the canvas using world coordinates
|
||||
* @param imageElement
|
||||
* @param xMin
|
||||
* @param yMin
|
||||
* @param width
|
||||
* @param height
|
||||
* @param angle
|
||||
*/
|
||||
public drawImage(
|
||||
imageElement: HTMLImageElement,
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number
|
||||
): void {
|
||||
if (this.batching) this.flushBatch();
|
||||
const [screenBasePoint, screenDimensions] = this.worldsToTargets([
|
||||
new Point(xMin, yMin),
|
||||
new Point(width, height),
|
||||
]);
|
||||
const screenXMin = screenBasePoint.x;
|
||||
const screenYMin = screenBasePoint.y;
|
||||
const screenWidth = screenDimensions.x;
|
||||
const screenHeight = screenDimensions.y;
|
||||
const screenCenterX = screenXMin + screenWidth / 2;
|
||||
const screenCenterY = screenYMin + screenHeight / 2;
|
||||
|
||||
// Rotate and translate context
|
||||
this.context.translate(screenCenterX, screenCenterY);
|
||||
this.context.rotate(angle);
|
||||
|
||||
// Draw image
|
||||
this.context.drawImage(
|
||||
imageElement,
|
||||
-screenWidth / 2,
|
||||
-screenHeight / 2,
|
||||
screenWidth,
|
||||
screenHeight
|
||||
);
|
||||
|
||||
// Reset context
|
||||
this.context.rotate(-angle);
|
||||
this.context.translate(-screenCenterX, -screenCenterY);
|
||||
}
|
||||
|
||||
public fillRect(xMin: number, yMin: number, width: number, height: number, color: string) {
|
||||
const screenMinPoint = this.worldToTarget(new Point(xMin, yMin));
|
||||
|
||||
this.fillRectScreen(
|
||||
screenMinPoint.x,
|
||||
screenMinPoint.y,
|
||||
width * this.screenScale,
|
||||
height * this.screenScale,
|
||||
color
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill rectangle with color, but interpret the provided coordinates as screen coordinates
|
||||
* @param xMin
|
||||
* @param yMin
|
||||
* @param width
|
||||
* @param height
|
||||
* @param color
|
||||
*/
|
||||
public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {
|
||||
if (this.batching) this.flushBatch();
|
||||
// TODO see if we need to replace this with a call to fillPolygon
|
||||
this.context.fillStyle = color;
|
||||
this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill polygon with color
|
||||
* @param points
|
||||
*/
|
||||
public fillPolygon(...points: Point[]) {
|
||||
if (this.batching) this.flushBatch();
|
||||
const screenPoints = points.map(this.worldToTarget.bind(this));
|
||||
this.context.beginPath();
|
||||
screenPoints.forEach((screenPoint, index) => {
|
||||
if (index === 0) {
|
||||
this.context.moveTo(screenPoint.x, this.canvasSize.y - screenPoint.y);
|
||||
} else {
|
||||
this.context.lineTo(screenPoint.x, this.canvasSize.y - screenPoint.y);
|
||||
}
|
||||
});
|
||||
this.context.closePath();
|
||||
this.context.fill();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import {Point, Vector} from '@flatten-js/core';
|
||||
import {toast} from 'react-toastify';
|
||||
import {SVG_MARGIN, TO_DEGREES} from '../App.consts.ts';
|
||||
import type {TextOptions} from '../entities/TextEntity.ts';
|
||||
import {isLengthEqual} from '../helpers/is-length-equal.ts';
|
||||
import {StateVariable} from '../helpers/undo-stack.ts';
|
||||
import {triggerReactUpdate} from '../state.ts';
|
||||
import {DEFAULT_TEXT_OPTIONS, type DrawController} from './DrawController';
|
||||
|
||||
export class SvgDrawController implements DrawController {
|
||||
private lineColor = '#000';
|
||||
private lineWidth = 1;
|
||||
private lineDash: number[] = [];
|
||||
private svgStrings: string[] = [];
|
||||
private fillColor = '#000';
|
||||
private screenScale = 1;
|
||||
private screenOffset = new Point(0, 0);
|
||||
|
||||
constructor(
|
||||
private boundingBoxMinX: number,
|
||||
private boundingBoxMinY: number,
|
||||
private boundingBoxMaxX: number,
|
||||
private boundingBoxMaxY: number
|
||||
) {
|
||||
this.setScreenOffset(new Point(boundingBoxMinX - SVG_MARGIN, boundingBoxMinY + SVG_MARGIN));
|
||||
}
|
||||
|
||||
getCanvasSize(): Point {
|
||||
return new Point(
|
||||
this.boundingBoxMaxX - this.boundingBoxMinX,
|
||||
this.boundingBoxMaxY - this.boundingBoxMinY
|
||||
);
|
||||
}
|
||||
|
||||
public getScreenScale() {
|
||||
return this.screenScale;
|
||||
}
|
||||
|
||||
public setScreenScale(newScreenScale: number) {
|
||||
this.screenScale = newScreenScale;
|
||||
triggerReactUpdate(StateVariable.screenZoom);
|
||||
}
|
||||
|
||||
public getScreenOffset() {
|
||||
return this.screenOffset;
|
||||
}
|
||||
|
||||
public setScreenOffset(newScreenOffset: Point) {
|
||||
this.screenOffset = newScreenOffset;
|
||||
triggerReactUpdate(StateVariable.screenOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates from World Space --> Screen Space
|
||||
*/
|
||||
public worldToTarget(worldCoordinate: Point): Point {
|
||||
return new Point(
|
||||
(worldCoordinate.x - this.screenOffset.x) * this.screenScale,
|
||||
-1 * ((worldCoordinate.y - this.screenOffset.y) * this.screenScale - this.getCanvasSize().y)
|
||||
);
|
||||
}
|
||||
|
||||
public worldsToTargets(worldCoordinates: Point[]): Point[] {
|
||||
return worldCoordinates.map(this.worldToTarget.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert coordinates from Screen Space --> World Space
|
||||
* (0, 0) (1920, 0)
|
||||
*
|
||||
* (0, 1080) (1920, 1080)
|
||||
*
|
||||
* convert to
|
||||
*
|
||||
* (0, 1080) (1920, 1080)
|
||||
*
|
||||
* (0, 0) (1920, 0)
|
||||
*/
|
||||
public targetToWorld(screenCoordinate: Point): Point {
|
||||
return new Point(
|
||||
screenCoordinate.x / this.screenScale + this.screenOffset.x,
|
||||
this.getCanvasSize().y - screenCoordinate.y / this.screenScale + this.screenOffset.y
|
||||
);
|
||||
}
|
||||
|
||||
public targetsToWorlds(screenCoordinates: Point[]): Point[] {
|
||||
return screenCoordinates.map(this.targetToWorld.bind(this));
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.svgStrings = [];
|
||||
}
|
||||
|
||||
public setLineStyles(
|
||||
_isHighlighted: boolean,
|
||||
_isSelected: boolean,
|
||||
lineColor: string,
|
||||
lineWidth: number,
|
||||
lineDash: number[] = []
|
||||
) {
|
||||
if (
|
||||
lineColor.toLowerCase() === '#fff' ||
|
||||
lineColor.toLowerCase() === '#ffffff' ||
|
||||
lineColor === 'white'
|
||||
) {
|
||||
this.lineColor = '#000';
|
||||
} else if (
|
||||
lineColor.toLowerCase() === '#000' ||
|
||||
lineColor.toLowerCase() === '#000000' ||
|
||||
lineColor === 'black'
|
||||
) {
|
||||
this.lineColor = '#FFF';
|
||||
} else {
|
||||
this.lineColor = lineColor;
|
||||
}
|
||||
this.lineWidth = lineWidth;
|
||||
this.lineDash = lineDash;
|
||||
}
|
||||
|
||||
public setFillStyles(fillColor: string) {
|
||||
if (
|
||||
fillColor.toLowerCase() === '#fff' ||
|
||||
fillColor.toLowerCase() === '#ffffff' ||
|
||||
fillColor === 'white'
|
||||
) {
|
||||
this.fillColor = '#000';
|
||||
} else if (
|
||||
fillColor.toLowerCase() === '#000' ||
|
||||
fillColor.toLowerCase() === '#000000' ||
|
||||
fillColor === 'black'
|
||||
) {
|
||||
this.fillColor = '#FFF';
|
||||
} else {
|
||||
this.fillColor = fillColor;
|
||||
}
|
||||
}
|
||||
|
||||
public export() {
|
||||
const boundingBoxWidth = Math.ceil(
|
||||
this.boundingBoxMaxX - this.boundingBoxMinX + SVG_MARGIN * 2
|
||||
);
|
||||
const boundingBoxHeight = Math.ceil(
|
||||
this.boundingBoxMaxY - this.boundingBoxMinY + SVG_MARGIN * 2
|
||||
);
|
||||
|
||||
const svgLines = [
|
||||
`<svg width="${boundingBoxWidth}" height="${boundingBoxHeight}" viewBox="0 0 ${boundingBoxWidth} ${boundingBoxHeight}" xmlns="http://www.w3.org/2000/svg">\n`,
|
||||
` <rect x="0" y="0" width="${boundingBoxWidth}" height="${boundingBoxHeight}" fill="#FFF" />\n`,
|
||||
...this.svgStrings.map((svgString) => `\t${svgString}\n`),
|
||||
'</svg>',
|
||||
];
|
||||
|
||||
return {
|
||||
svgLines,
|
||||
width: boundingBoxWidth,
|
||||
height: boundingBoxHeight,
|
||||
};
|
||||
}
|
||||
|
||||
public drawLine(startPoint: Point, endPoint: Point): void {
|
||||
const [canvasStartPoint, canvasEndPoint] = this.worldsToTargets([startPoint, endPoint]);
|
||||
this.svgStrings.push(
|
||||
`<line x1="${canvasStartPoint.x}" y1="${canvasStartPoint.y}" x2="${canvasEndPoint.x}" y2="${canvasEndPoint.y}" stroke="${this.lineColor}" stroke-width="${this.lineWidth}" stroke-dasharray="${this.lineDash.join(',')}" stroke-linecap="round" />`
|
||||
);
|
||||
}
|
||||
|
||||
public drawArc(
|
||||
centerPoint: Point,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
counterClockwise: boolean
|
||||
) {
|
||||
const canvasCenterPoint = this.worldToTarget(centerPoint);
|
||||
const canvasRadius = radius * this.screenScale;
|
||||
|
||||
// Calculate start and end points of the arc
|
||||
let startPoint = new Point(canvasCenterPoint.x + canvasRadius, canvasCenterPoint.y);
|
||||
startPoint = startPoint.rotate(startAngle, canvasCenterPoint);
|
||||
let endPoint = new Point(canvasCenterPoint.x + canvasRadius, canvasCenterPoint.y);
|
||||
endPoint = endPoint.rotate(endAngle, canvasCenterPoint);
|
||||
|
||||
// Normalize the sweep angle to be between 0 and 2π
|
||||
let sweep = endAngle - startAngle;
|
||||
if (counterClockwise && sweep > 0) {
|
||||
sweep -= 2 * Math.PI;
|
||||
} else if (!counterClockwise && sweep < 0) {
|
||||
sweep += 2 * Math.PI;
|
||||
}
|
||||
|
||||
const largeArcFlag = Math.abs(sweep) > Math.PI ? '1' : '0';
|
||||
const sweepFlag = counterClockwise ? '0' : '1'; // SVG: 0 = CCW, 1 = CW
|
||||
|
||||
const attributes = `fill="none" stroke="${this.lineColor}" stroke-width="${this.lineWidth}" stroke-dasharray="${this.lineDash.join(',')}" stroke-linecap="round"`;
|
||||
let svgPath: string;
|
||||
if (isLengthEqual(sweep, 2 * Math.PI)) {
|
||||
svgPath = `<circle cx="${canvasCenterPoint.x}" cy="${canvasCenterPoint.y}" r="${canvasRadius}" ${attributes} />`;
|
||||
} else {
|
||||
svgPath = `<path d="M${startPoint.x},${startPoint.y} A${canvasRadius},${canvasRadius} 0 ${largeArcFlag},${sweepFlag} ${endPoint.x},${endPoint.y}" ${attributes} />`;
|
||||
}
|
||||
|
||||
// Push the SVG path data string to the svgStrings array
|
||||
this.svgStrings.push(svgPath);
|
||||
}
|
||||
|
||||
public drawText(label: string, basePoint: Point, options?: Partial<TextOptions>): void {
|
||||
const canvasBasePoint = this.worldToTarget(basePoint);
|
||||
|
||||
const textOptions = {
|
||||
...DEFAULT_TEXT_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
|
||||
let finalTextColor = textOptions.textColor;
|
||||
const lowerCaseTextColor = textOptions.textColor.toLowerCase();
|
||||
if (
|
||||
lowerCaseTextColor === '#fff' ||
|
||||
lowerCaseTextColor === '#ffffff' ||
|
||||
lowerCaseTextColor === 'white'
|
||||
) {
|
||||
finalTextColor = '#000'; // Change to black if current color is white
|
||||
}
|
||||
// No need to handle black to white, as SVG background is white.
|
||||
// Other colors will remain as they are.
|
||||
|
||||
let transformAttribute = '';
|
||||
if (textOptions.textDirection) {
|
||||
const angle = textOptions.textDirection.angleTo(new Vector(1, 0)) * TO_DEGREES;
|
||||
transformAttribute = `transform="rotate(${angle}, ${canvasBasePoint.x}, ${canvasBasePoint.y})"`;
|
||||
}
|
||||
|
||||
let textAnchorAttribute = '';
|
||||
if (textOptions.textAlign === 'center') {
|
||||
textAnchorAttribute = 'text-anchor="middle"';
|
||||
}
|
||||
|
||||
this.svgStrings.push(
|
||||
// Use finalTextColor here
|
||||
`<text x="${canvasBasePoint.x}" y="${canvasBasePoint.y}" fill="${finalTextColor}" font-size="${textOptions.fontSize}" font-family="${textOptions.fontFamily}" ${transformAttribute} ${textAnchorAttribute}>${label}</text>`
|
||||
);
|
||||
}
|
||||
|
||||
public drawImage(
|
||||
imageElement: HTMLImageElement,
|
||||
xMin: number,
|
||||
yMin: number,
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number
|
||||
): void {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageElement.width;
|
||||
canvas.height = imageElement.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
toast.warn('Failed to create canvas context');
|
||||
console.warn('Failed to create canvas context');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(imageElement, 0, 0);
|
||||
const dataUri = canvas.toDataURL(); // Convert the image to Base64
|
||||
|
||||
const svgWidth = width * this.getScreenScale();
|
||||
const svgHeight = height * this.getScreenScale();
|
||||
|
||||
const worldCenterX = xMin + width / 2;
|
||||
const worldCenterY = yMin + height / 2;
|
||||
|
||||
const targetCenter = this.worldToTarget(new Point(worldCenterX, worldCenterY));
|
||||
|
||||
const svgX = targetCenter.x - svgWidth / 2;
|
||||
const svgY = targetCenter.y - svgHeight / 2;
|
||||
|
||||
let transformAttribute = '';
|
||||
if (angle !== 0) {
|
||||
const svgAngleDegrees = angle * (180 / Math.PI);
|
||||
transformAttribute = `transform="rotate(${svgAngleDegrees}, ${targetCenter.x}, ${targetCenter.y})"`;
|
||||
}
|
||||
|
||||
// noinspection HtmlUnknownAttribute
|
||||
this.svgStrings.push(
|
||||
`<image href="${dataUri}" x="${svgX}" y="${svgY}" width="${svgWidth}" height="${svgHeight}" ${transformAttribute} />`
|
||||
);
|
||||
}
|
||||
|
||||
public fillPolygon(...points: Point[]) {
|
||||
if (points.length < 3) return; // Polygon needs at least 3 points
|
||||
const canvasPoints = this.worldsToTargets(points);
|
||||
|
||||
const pointsString = canvasPoints.map((p) => `${p.x},${p.y}`).join(' ');
|
||||
this.svgStrings.push(`<polygon points="${pointsString}" fill="${this.fillColor}" />`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user