662 lines
20 KiB
TypeScript
662 lines
20 KiB
TypeScript
import { Point, type Vector } from '@flatten-js/core';
|
|
import { 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 { paintColor, themeColor } from '../theme.ts';
|
|
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
|
|
|
|
/** 토큰을 못 읽을 때만 쓰는 캔버스 배경 폴백. */
|
|
const CANVAS_BACKGROUND_FALLBACK = '#111';
|
|
|
|
/**
|
|
* 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,
|
|
rawColor: string,
|
|
lineWidth: number,
|
|
dash: number[] = []
|
|
) {
|
|
// 저장된 도면 색은 다크 배경 기준 — 라이트 테마에서는 그릴 때만 대비를 맞춘다.
|
|
const color = paintColor(rawColor);
|
|
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 = paintColor(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 = themeColor('--cad-canvas', CANVAS_BACKGROUND_FALLBACK);
|
|
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
|
|
if (getGridEnabled()) {
|
|
this.context.strokeStyle = themeColor('--color-mist', '#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 = paintColor(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 = paintColor(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();
|
|
}
|
|
}
|