@webwriter/geometry-cloze
Version:
Create and view geometry exercises with coloring, styling and labeling options.
648 lines (579 loc) • 21.5 kB
text/typescript
import Calc, { MathPoint } from '../helper/Calc';
import Arrays from '../helper/Arrays';
import Element from './base/Element';
import Draggable, { DraggableData } from './base/Draggable';
import Point, { BasePoint } from './Point';
import Line, { BaseLine } from './Line';
import { ContextMenuItem } from '../../types/ContextMenu';
import Vector from '../helper/Vector';
import Stylable, { StylableData } from './base/Stylable';
import Numbers from '../helper/Numbers';
import Manager from '../CanvasManager/Abstracts';
export default class Shape extends Draggable {
static createPolygon(manager: Manager, points: BasePoint[]): Shape;
static createPolygon(manager: Manager, ...points: BasePoint[]): Shape;
static createPolygon(
manager: Manager,
...points: [BasePoint[]] | BasePoint[]
): Shape {
let pointsArr: BasePoint[];
if (Array.isArray(points[0])) pointsArr = points[0];
else pointsArr = points as BasePoint[];
if (pointsArr.length < 3)
throw new Error("Can't create polygon with less than three points");
const children: (BasePoint | BaseLine)[] = [];
let lastPoint: Point | null = null;
for (const BasePoint of pointsArr) {
const point =
BasePoint instanceof Point ? BasePoint : new Point(manager, BasePoint);
if (lastPoint)
children.push(new Line(manager, { start: lastPoint, end: point }));
children.push(point);
lastPoint = point;
}
children.push(
new Line(manager, { start: lastPoint!, end: children[0] as Point })
);
return new Shape(manager, children, { closed: true });
}
static createPoint(manager: Manager, point: BasePoint) {
const pointElement =
point instanceof Point ? point : new Point(manager, point);
return new Shape(manager, [pointElement], { closed: false });
}
static createLine(manager: Manager, line: BaseLine) {
const start = new Point(manager, line.start);
const end = new Point(manager, line.end);
const lineElement =
line instanceof Line ? line : new Line(manager, { start, end });
return new Shape(manager, [start, lineElement, end], { closed: false });
}
protected closed = true;
constructor(
manager: Manager,
children: (BasePoint | BaseLine)[],
data?: DraggableData &
StylableData & {
closed?: boolean;
showArea?: boolean;
showPerimeter?: boolean;
}
) {
super(manager, data);
if (data?.showArea !== undefined) this.showArea = data.showArea;
if (data?.showPerimeter !== undefined)
this.showPerimeter = data.showPerimeter;
this.closed = children.length >= 3 && (data?.closed ?? true);
const childrenElements = children.map((child) => {
if (child instanceof Line || child instanceof Point) return child;
if ('start' in child && 'end' in child)
return new Line(this.manager, child);
if (child.x !== undefined && child.y !== undefined) {
return new Point(this.manager, child);
}
throw new Error('Invalid child type');
});
this.addChild(...childrenElements);
}
protected calculateOrigin() {
const points = this.getPoints();
const minX = Calc.getExtremePoint(points, 'min', 'x');
const minY = Calc.getExtremePoint(points, 'min', 'y');
if (minX) this._x = minX.x;
if (minY) this._y = minY.y;
}
protected addChildAt(child: Element, index: number): void {
super.addChildAt(child, index);
this.calculateOrigin();
}
protected addChild(...children: Element[]): void {
super.addChild(...children);
this.calculateOrigin();
}
/**
* Remove child without checking for shape validity
*/
protected removeChildUnsafe(child: Element): void {
super.removeChild(child);
}
protected removeChild(child: Element): void {
if (child instanceof Point) this.removePoint(child);
else if (child instanceof Line) this.removeLine(child);
else super.removeChild(child);
this.checkShapeValidity();
}
public move(coords: {
x?: number | undefined;
y?: number | undefined;
relative: boolean;
}): void {
const points = this.getPoints();
const relativeCoords = coords.relative
? coords
: {
x: (coords?.x ?? this._x) - this._x,
y: (coords?.y ?? this._y) - this._y,
relative: true
};
this._x += relativeCoords.x ?? 0;
this._y += relativeCoords.y ?? 0;
for (const point of points) {
point.move(relativeCoords);
}
this.fireEvent('move');
this.requestRedraw();
}
getPoints() {
return this.children.filter((child) => child instanceof Point) as Point[];
}
getLines() {
return this.children.filter((child) => child instanceof Line) as Line[];
}
getHit(point: MathPoint, point2?: MathPoint): Draggable[] {
if (this.hidden) return [];
const res: Draggable[] = [];
// points should be first, since they have higher priority to be selected
const hits = super.getHit(point, point2).sort((a, b) => {
if (a instanceof Point) return b instanceof Point ? 0 : -10;
else return b instanceof Point ? 10 : 0;
});
if (!point2 && hits.length > 0) return hits;
res.push(...hits);
if (this.closed && Calc.isPointInPolygon(point, this.getPoints()))
res.unshift(this);
return res;
}
addPoint(basePoint: BasePoint, to?: Point): Point | null {
const point = new Point(this.manager, basePoint);
const ownPoints = this.getPoints();
if (to) {
if (!this.hasChild(to)) return null;
if (this.closed) return null;
if (!this.isEndPoint(to)) return null;
}
const nearestLine = this.getLines().reduce(
(nearest, curr) => {
const dist = Calc.distance(curr, point);
if (dist >= nearest.dist) return nearest;
return { dist, line: curr };
},
{ dist: Infinity, line: null } as {
dist: number;
line: Line | null;
}
);
// if shape is not closed we check if the point is on a line otherwise we connect to the nearest endpoint
if (!nearestLine.line) {
this.addChild(
new Line(this.manager, { start: ownPoints.slice(-1)[0], end: point }),
point
);
} else if (this.closed || nearestLine.dist < 2) {
const lineIndex = this.children.indexOf(nearestLine.line);
const start = Arrays.at(this.children, lineIndex - 1);
const end = Arrays.at(this.children, lineIndex + 1);
if (
!start ||
!end ||
!(start instanceof Point) ||
!(end instanceof Point)
)
return null;
const line1 = new Line(this.manager, { start, end: point });
const line2 = new Line(this.manager, { start: point, end });
this.removeChildUnsafe(nearestLine.line);
this.addChildAt(line2, lineIndex);
this.addChildAt(point, lineIndex);
this.addChildAt(line1, lineIndex);
} else {
const nearestEndPoint =
to ??
[ownPoints[0], Arrays.at(ownPoints, -1)!].reduce(
(nearest, curr) => {
const dist = Calc.distance(curr, point);
if (dist >= nearest.dist) return nearest;
return { dist, point: curr };
},
{ dist: Infinity, point: null } as {
dist: number;
point: Point | null;
}
).point;
const line = new Line(this.manager, {
start: nearestEndPoint!,
end: point
});
const isStart = nearestEndPoint === ownPoints[0];
if (isStart) this.resortChildren((children) => children.reverse());
this.addChild(line);
this.addChild(point);
}
this.requestRedraw();
return point;
}
removePoint(point: Point) {
const index = this.children.findIndex((ele) => ele === point);
if (index === -1) return;
const nextIndex = (index + 1 + this.children.length) % this.children.length;
const nextLine = this.children[nextIndex];
if (nextLine && nextLine instanceof Line) this.removeChildUnsafe(nextLine);
const previousIndex =
(index - 1 + this.children.length) % this.children.length;
const previousLine = this.children[previousIndex];
if (previousLine && previousLine instanceof Line)
this.removeChildUnsafe(previousLine);
if (
previousLine &&
nextLine &&
previousLine instanceof Line &&
nextLine instanceof Line &&
this.children.length >= 5 // at least three points and two lines
) {
const start = previousLine.start;
const end = nextLine.end;
// check if these points are already connected
const newLine = new Line(this.manager, {
start,
end
});
this.addChildAt(newLine, previousIndex);
}
this.removeChildUnsafe(point);
this.checkShapeValidity();
}
connectPoints(point1: Point, point2: Point) {
if (this.closed) return;
const start = this.children.indexOf(point1);
const end = this.children.indexOf(point2);
const lastIndex = this.children.length - 1;
// cant connect anything but the endpoints
if (
!(start === 0 && end === lastIndex) &&
!(start === lastIndex && end === 0)
)
return;
// if shape is only two points that are already connected, we dont need to do anything
// lastIndex is 2 since 0 is the first point and 1 is the line
if (lastIndex === 2) return;
const line = new Line(this.manager, { start: point1, end: point2 });
this.addChild(line);
this.closed = true;
this.checkShapeValidity();
this.requestRedraw();
}
removeLine(line: Line) {
const index = this.children.indexOf(line);
if (index === -1) return;
// when shape is only two points connected by a line, we delete the shape
if (this.children.length === 3) return this.delete();
this.removeChildUnsafe(line);
// resort children, so that the new formed gap is now at the start + end
const start = this.children.slice(index);
const end = this.children.slice(0, index);
const sortedChildren = start.concat(end);
this.resortChildren(() => sortedChildren);
this.closed = false;
this.checkShapeValidity();
}
isEndPoint(point: Point) {
if (this.closed) return false;
return this.children[0] === point || Arrays.at(this.children, -1) === point;
}
/**
* Checks is still connected
*/
private checkShapeValidity() {
const shapes: { elements: (Line | Point)[]; closed: boolean }[] = [];
// remove all children that are neither points nor lines
// remove all lines that are not connected to two points
const filteredChildren = this.children.filter((child, index) => {
const isLine = child instanceof Line;
const isPoint = child instanceof Point;
if (!isLine && !isPoint) return false;
const isNextALine = this.children[index + 1] instanceof Line;
const isPreviousALine = this.children[index - 1] instanceof Line;
if (isLine && (isPreviousALine || isNextALine)) return false;
return true;
});
let startIndex =
filteredChildren.findIndex((child, index) => {
const current = child;
const next = filteredChildren[index + 1];
if (current instanceof Point && next instanceof Point) return true;
if (current instanceof Line && next instanceof Line) return true;
return false;
}) + 1;
// sort children so that when shape is a line, that the point where it is split is at the end
const sortedChildren = filteredChildren
.slice(startIndex)
.concat(filteredChildren.slice(0, startIndex));
let lastElement: Line | Point | null = null;
for (const element of sortedChildren as (Line | Point)[]) {
const currentShape = shapes.slice(-1)[0];
if (lastElement instanceof Point) {
if (element instanceof Point) {
// to points without connection -> new shape
shapes.push({ elements: [element], closed: false });
}
if (element instanceof Line) {
if (element.isEndpoint(lastElement)) {
// valid shape
currentShape.elements.push(element);
} else {
// not connected to last point -> new shape
// but shape cant start with a line, so we create an empty shape
shapes.push({ elements: [], closed: false });
}
}
}
if (lastElement instanceof Line) {
if (element instanceof Point) {
if (lastElement.isEndpoint(element)) {
// valid shape
currentShape.elements.push(element);
} else {
// since last shape is now finished, we can check if it is closed
currentShape.closed = lastElement.isEndpoint(
currentShape.elements[0]
);
// not connected to last line -> new shape
shapes.push({ elements: [element], closed: false });
}
}
if (element instanceof Line) {
// since last shape is now finished, we can check if it is closed
currentShape.closed = lastElement.isEndpoint(
currentShape.elements[0]
);
// two lines without point -> new shape
// but shape cant start with a line, so we create an empty shape
shapes.push({ elements: [], closed: false });
}
}
if (!lastElement) {
if (element instanceof Point) {
// create new shape
shapes.push({ elements: [element], closed: false });
}
}
const newesetShapeElement = shapes.slice(-1)[0].elements.slice(-1)[0];
lastElement = newesetShapeElement ?? null;
}
if (lastElement instanceof Line)
shapes.slice(-1)[0].closed = lastElement.isEndpoint(
shapes.slice(-1)[0].elements[0]
);
const nonEmptyShapes = shapes.filter((shape) => shape.elements.length > 0);
const self = nonEmptyShapes.pop();
if (!self) {
this.delete();
return;
}
this.closed = self.closed;
const removedElements = this.children.filter(
(child) => !self.elements.includes(child as Line | Point)
);
for (const element of removedElements) this.removeChildUnsafe(element);
for (const newShape of nonEmptyShapes) {
const shape = new Shape(this.manager, newShape.elements, {
...this.export(),
closed: newShape.closed
});
this.manager.addChild(shape);
}
this.requestRedraw();
}
draw(ctx: CanvasRenderingContext2D): void {
if (this.hidden) return;
if (this.selected) {
ctx.shadowBlur = 5;
ctx.shadowColor = '#000000b0';
ctx.shadowOffsetX = 5;
ctx.shadowOffsetY = 5;
}
if (this.closed) {
ctx.strokeStyle = 'transparent';
ctx.fillStyle =
this.selected && this.fill === 'transparent' ? '#ffffff50' : this.fill;
ctx.beginPath();
const points = this.getPoints();
const lastPoint = points.slice(-1)[0] as Point | undefined;
if (!lastPoint) return;
ctx.moveTo(lastPoint.x, lastPoint.y);
for (const point of points) {
ctx.lineTo(point.x, point.y);
ctx.stroke();
}
ctx.closePath();
ctx.fill();
} else if (this.selected) {
ctx.strokeStyle = 'transparent';
ctx.beginPath();
const points = this.getPoints();
const lastPoint = points.slice(-1)[0] as Point | undefined;
if (!lastPoint) return;
ctx.moveTo(lastPoint.x, lastPoint.y);
for (const point of points) {
ctx.lineTo(point.x, point.y);
ctx.stroke();
}
}
super.draw(ctx);
if (this.showLabel) {
const labels = this.getLabel().split('|');
ctx.font = '24px Arial';
ctx.fillStyle = this.labelColor;
const metrics = labels.map((label) => ctx.measureText(label));
const fontHeight = metrics.map(
(metric) => metric.fontBoundingBoxAscent + metric.fontBoundingBoxDescent
);
const points = this.getPoints();
let middle = points.reduce((acc, point) => Vector.add(acc, point), {
x: 0,
y: 0
});
middle = Vector.scale(middle, 1 / points.length);
let offset = 0;
labels.forEach((label, index) => {
ctx.fillText(
label,
middle.x - metrics[index].width / 2,
middle.y + fontHeight[index] / 4 + offset
);
offset += fontHeight[index];
});
}
}
protected showArea = false;
protected showPerimeter = false;
protected getValueLabel(): string {
const res: (string | number)[] = [];
if (this.showArea) {
const area = Calc.getAreaOfPolygon(this.getPoints());
const scaledArea = area * this.manager.scale ** 2;
const areaRounded = Numbers.round(scaledArea);
const prefix = this.showPerimeter ? 'Area: ' : '';
res.push(`${prefix}${areaRounded}`);
}
if (this.showPerimeter) {
const perimeter = Calc.getPerimeterOfPolygon(
this.getPoints().map((p) => Vector.scale(p, this.manager.scale))
);
const perimeterRounded = Numbers.round(perimeter);
const prefix = 'Perimeter: ';
res.push(`${prefix}${perimeterRounded}`);
}
return res.join('|');
}
public getContextMenuItems(): ContextMenuItem[] {
return [
...super.getContextMenuItems(),
...this.getStyleContextMenuItems({
fill: true,
showLabel: false
}),
{
type: 'submenu',
label: 'Label',
key: 'label',
items: [
{
type: 'checkbox',
label: 'Show Area',
getChecked: () => this.showArea,
action: (checked) => {
this.showArea = checked;
this.shouldShowLabel(this.showArea || this.showPerimeter);
this.requestRedraw();
},
key: 'show-area-label'
},
{
type: 'checkbox',
label: 'Show Perimeter',
getChecked: () => this.showPerimeter,
action: (checked) => {
this.showPerimeter = checked;
this.shouldShowLabel(this.showArea || this.showPerimeter);
this.requestRedraw();
},
key: 'show-perimeter-label'
},
{
type: 'submenu',
label: 'Color',
key: 'label_color',
items: Stylable.COLORS.map(
(option) =>
({
type: 'checkbox',
getChecked: () => this.labelColor === option.color,
label: option.label,
action: () => this.setLabelColor(option.color),
key: `label_color_${option.label.toLowerCase()}`
}) as const
)
}
]
}
];
}
public connect(shape: Shape, point: Point, to: Point) {
// cannot connect when shape is already closed
if (this.closed || shape.closed) return;
if (shape === this) return this.connectPoints(point, to);
if (Arrays.at(this.children, -1) !== point) {
if (this.children[0] === point)
this.resortChildren((children) => children.reverse());
else {
// cant connect to this point since it is not an endpoint
return;
}
}
const newChildren = shape.children.slice(); // copy the array
if (newChildren[0] !== to) {
if (Arrays.at(newChildren, -1) === to) newChildren.reverse();
else {
// cant connect to this point since it is not an endpoint
return;
}
}
const line = new Line(this.manager, { start: point, end: to });
this.addChild(line);
shape.delete();
this.addChild(...newChildren);
}
public export() {
return {
...super.export(),
closed: this.closed,
...(this.showArea && { showArea: true }),
...(this.showPerimeter && { showPerimeter: true })
};
}
public static import(data: ReturnType<Shape['export']>, manager: Manager) {
const points =
data.children
?.filter((child) => child._type === 'point')
.map((child) => Point.import(child as any, manager)) ?? [];
const children =
(data.children
?.map((child: any) => {
if (child._type === 'point')
return points.find((point) => point.id === child.id);
if (child._type === 'line')
return Line.import(
{
...child,
start: points.find((point) => point.id === child.start.id),
end: points.find((point) => point.id === child.end.id)
},
manager
);
if (child._type === 'element') return null;
throw new Error('Invalid child type');
})
.filter(Boolean) as (Point | Line)[]) ?? [];
return new Shape(manager, children, data);
}
}