310 lines
10 KiB
JavaScript
310 lines
10 KiB
JavaScript
|
import {Path} from '@svgdotjs/svg.js';
|
||
|
import {Geo} from './Geo.js';
|
||
|
import {angle, rng, TAU, getPointOnCircle, calculateDistance,
|
||
|
clockwiseAngleDist} from '../shapes/helpers.js';
|
||
|
/**
|
||
|
* Class representing a Cloud shape.
|
||
|
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/shapes/geo/cloudOutline.ts} Adapted from Tldraw.
|
||
|
*/
|
||
|
export class Cloud extends Geo {
|
||
|
/**
|
||
|
* Generate points on an arc between two given points.
|
||
|
*
|
||
|
* @param {Object} startPoint - Starting point with 'x' and 'y' properties.
|
||
|
* @param {Object} endPoint - End point with 'x' and 'y' properties.
|
||
|
* @param {Object|null} center - Center point with 'x' and 'y' properties
|
||
|
* @param {number} radius - The radius of the circle.
|
||
|
* @param {number} numPoints - The number of points to generate along the arc.
|
||
|
* @return {Array} Array of point objects representing the points on the arc.
|
||
|
*/
|
||
|
static pointsOnArc(startPoint, endPoint, center, radius, numPoints) {
|
||
|
if (center === null) {
|
||
|
return [startPoint, endPoint];
|
||
|
}
|
||
|
|
||
|
const results = [];
|
||
|
const startAngle = angle(center, startPoint);
|
||
|
const endAngle = angle(center, endPoint);
|
||
|
const l = clockwiseAngleDist(startAngle, endAngle);
|
||
|
|
||
|
for (let i = 0; i < numPoints; i++) {
|
||
|
const t = i / (numPoints - 1);
|
||
|
const angle = startAngle + l * t;
|
||
|
const point = getPointOnCircle(center.x, center.y, radius, angle);
|
||
|
results.push(point);
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Function to get points on the "pill" shape.
|
||
|
*
|
||
|
* @static
|
||
|
* @param {number} width - The width of the pill shape.
|
||
|
* @param {number} height - The height of the pill shape.
|
||
|
* @param {number} numPoints - The number of points to generate.
|
||
|
* @return {Array} - Array of points on the pill shape.
|
||
|
*/
|
||
|
static getPillPoints(width, height, numPoints) {
|
||
|
const radius = Math.min(width, height) / 2;
|
||
|
const longSide = Math.max(width, height) - radius * 2;
|
||
|
const circumference = TAU * radius + 2 * longSide;
|
||
|
const spacing = circumference / numPoints;
|
||
|
|
||
|
const sections = width > height ?
|
||
|
[
|
||
|
{type: 'straight', start: {x: radius, y: 0}, delta: {x: 1, y: 0}},
|
||
|
{type: 'arc', center: {x: width - radius, y: radius},
|
||
|
startAngle: -TAU / 4},
|
||
|
{type: 'straight', start: {x: width - radius, y: height},
|
||
|
delta: {x: -1, y: 0}},
|
||
|
{type: 'arc', center: {x: radius, y: radius}, startAngle: TAU / 4},
|
||
|
] :
|
||
|
[
|
||
|
{type: 'straight', start: {x: width, y: radius}, delta: {x: 0, y: 1}},
|
||
|
{type: 'arc', center: {x: radius, y: height - radius}, startAngle: 0},
|
||
|
{type: 'straight', start: {x: 0, y: height - radius},
|
||
|
delta: {x: 0, y: - 1}},
|
||
|
{type: 'arc', center: {x: radius, y: radius}, startAngle: TAU / 2},
|
||
|
];
|
||
|
|
||
|
let sectionOffset = 0;
|
||
|
const points = [];
|
||
|
for (let i = 0; i < numPoints; i++) {
|
||
|
const section = sections[0];
|
||
|
if (section.type === 'straight') {
|
||
|
points.push({
|
||
|
x: section.start.x + section.delta.x * sectionOffset,
|
||
|
y: section.start.y + section.delta.y * sectionOffset,
|
||
|
});
|
||
|
} else {
|
||
|
points.push(getPointOnCircle(
|
||
|
section.center.x,
|
||
|
section.center.y,
|
||
|
radius,
|
||
|
section.startAngle + sectionOffset / radius,
|
||
|
));
|
||
|
}
|
||
|
sectionOffset += spacing;
|
||
|
let sectionLength =
|
||
|
section.type === 'straight' ? longSide : (TAU / 2) * radius;
|
||
|
while (sectionOffset > sectionLength) {
|
||
|
sectionOffset -= sectionLength;
|
||
|
sections.push(sections.shift());
|
||
|
sectionLength =
|
||
|
sections[0].type === 'straight' ? longSide : (TAU / 2) * radius;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return points;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns a numerical value based on the given size parameter.
|
||
|
*
|
||
|
* @static
|
||
|
* @param {string} size - The size style, one of: 's', 'm', 'l', 'xl'.
|
||
|
* @return {number} The numerical value corresponding to the given size.
|
||
|
* @throws Will default to 130 if the size parameter doesn't match any case.
|
||
|
*/
|
||
|
static switchSize(size) {
|
||
|
switch (size) {
|
||
|
case 's':
|
||
|
return 50;
|
||
|
case 'm':
|
||
|
return 70;
|
||
|
case 'l':
|
||
|
return 100;
|
||
|
case 'xl':
|
||
|
return 130;
|
||
|
default:
|
||
|
return 130;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Calculates the circumference of a pill shape.
|
||
|
*
|
||
|
* A pill shape is a rectangle with semi-circular ends. The function
|
||
|
* calculates the total distance around the shape using its width and height.
|
||
|
*
|
||
|
* @static
|
||
|
* @param {number} width - The width of the pill shape.
|
||
|
* @param {number} height - The height of the pill shape.
|
||
|
* @return {number} The circumference of the pill shape.
|
||
|
*/
|
||
|
static getPillCircumference(width, height) {
|
||
|
const radius = Math.min(width, height) / 2;
|
||
|
const longSide = Math.max(width, height) - radius * 2;
|
||
|
|
||
|
return TAU * radius + 2 * longSide;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get arcs for generating a cloud shape.
|
||
|
*
|
||
|
* @static
|
||
|
* @param {number} width - The width of the cloud.
|
||
|
* @param {number} height - The height of the cloud.
|
||
|
* @param {string} seed - The random seed for the cloud.
|
||
|
* @param {Object} size - The size style for the cloud.
|
||
|
* @return {Array} An array of arcs data.
|
||
|
*/
|
||
|
static getCloudArcs(width, height, seed, size) {
|
||
|
const getRandom = rng(seed);
|
||
|
|
||
|
const pillCircumference = Cloud.getPillCircumference(width, height);
|
||
|
|
||
|
const numBumps = Math.max(
|
||
|
Math.ceil(pillCircumference / Cloud.switchSize(size)),
|
||
|
6,
|
||
|
Math.ceil(pillCircumference / Math.min(width, height)),
|
||
|
);
|
||
|
|
||
|
const targetBumpProtrusion = (pillCircumference / numBumps) * 0.2;
|
||
|
|
||
|
const innerWidth = Math.max(width - targetBumpProtrusion * 2, 1);
|
||
|
const innerHeight = Math.max(height - targetBumpProtrusion * 2, 1);
|
||
|
const paddingX = (width - innerWidth) / 2;
|
||
|
const paddingY = (height - innerHeight) / 2;
|
||
|
|
||
|
const distanceBetweenPointsOnPerimeter =
|
||
|
Cloud.getPillCircumference(innerWidth, innerHeight) / numBumps;
|
||
|
|
||
|
let bumpPoints = Cloud.getPillPoints(innerWidth, innerHeight, numBumps);
|
||
|
bumpPoints = bumpPoints.map((p) => {
|
||
|
return {
|
||
|
x: p.x + paddingX,
|
||
|
y: p.y + paddingY,
|
||
|
};
|
||
|
});
|
||
|
|
||
|
const maxWiggleX = width < 20 ? 0 : targetBumpProtrusion * 0.3;
|
||
|
const maxWiggleY = height < 20 ? 0 : targetBumpProtrusion * 0.3;
|
||
|
const wiggledPoints = bumpPoints.slice(0);
|
||
|
|
||
|
for (let i = 0; i < Math.floor(numBumps / 2); i++) {
|
||
|
wiggledPoints[i].x += getRandom() * maxWiggleX;
|
||
|
wiggledPoints[i].y += getRandom() * maxWiggleY;
|
||
|
wiggledPoints[numBumps - i - 1].x += getRandom() * maxWiggleX;
|
||
|
wiggledPoints[numBumps - i - 1].y += getRandom() * maxWiggleY;
|
||
|
}
|
||
|
|
||
|
const arcs = [];
|
||
|
|
||
|
for (let i = 0; i < wiggledPoints.length; i++) {
|
||
|
const j = i === wiggledPoints.length - 1 ? 0 : i + 1;
|
||
|
const leftWigglePoint = wiggledPoints[i];
|
||
|
const rightWigglePoint = wiggledPoints[j];
|
||
|
const leftPoint = bumpPoints[i];
|
||
|
const rightPoint = bumpPoints[j];
|
||
|
|
||
|
const midPoint = {
|
||
|
x: (leftPoint.x + rightPoint.x) / 2,
|
||
|
y: (leftPoint.y + rightPoint.y) / 2,
|
||
|
};
|
||
|
|
||
|
const offsetAngle = Math.atan2(rightPoint.y - leftPoint.y,
|
||
|
rightPoint.x - leftPoint.x) - TAU;
|
||
|
|
||
|
const distanceBetweenOriginalPoints =
|
||
|
Math.sqrt(Math.pow(rightPoint.x - leftPoint.x, 2) +
|
||
|
Math.pow(rightPoint.y - leftPoint.y, 2));
|
||
|
|
||
|
const curvatureOffset =
|
||
|
distanceBetweenPointsOnPerimeter - distanceBetweenOriginalPoints;
|
||
|
|
||
|
const distanceBetweenWigglePoints =
|
||
|
Math.sqrt(Math.pow(rightWigglePoint.x - leftWigglePoint.x, 2) +
|
||
|
Math.pow(rightWigglePoint.y - leftWigglePoint.y, 2));
|
||
|
|
||
|
const relativeSize =
|
||
|
distanceBetweenWigglePoints / distanceBetweenOriginalPoints;
|
||
|
const finalDistance = (Math.max(paddingX, paddingY) +
|
||
|
curvatureOffset) * relativeSize;
|
||
|
|
||
|
const arcPoint = {
|
||
|
x: midPoint.x + Math.cos(offsetAngle) * finalDistance,
|
||
|
y: midPoint.y + Math.sin(offsetAngle) * finalDistance,
|
||
|
};
|
||
|
|
||
|
arcPoint.x = Math.min(Math.max(arcPoint.x, 0), width);
|
||
|
arcPoint.y = Math.min(Math.max(arcPoint.y, 0), height);
|
||
|
|
||
|
arcs.push({
|
||
|
leftPoint: leftWigglePoint,
|
||
|
rightPoint: rightWigglePoint,
|
||
|
arcPoint,
|
||
|
});
|
||
|
}
|
||
|
|
||
|
return arcs;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Generate an SVG path string to represent a cloud shape using arc segments.
|
||
|
*
|
||
|
* @param {number} width - The width of the cloud.
|
||
|
* @param {number} height - The height of the cloud.
|
||
|
* @param {number} seed - The seed value for randomization (if applicable).
|
||
|
* @param {number} size - The size of the cloud.
|
||
|
* @return {string} - An SVG path string representing the cloud.
|
||
|
*/
|
||
|
static cloudSvgPath(width, height, seed, size) {
|
||
|
// Get cloud arcs based on input parameters
|
||
|
const arcs = Cloud.getCloudArcs(width, height, seed, size);
|
||
|
|
||
|
// Initialize SVG path starting with the 'M' command
|
||
|
// for the first arc's leftPoint
|
||
|
const initialX = arcs[0].leftPoint.x.toFixed(2);
|
||
|
const initialY = arcs[0].leftPoint.y.toFixed(2);
|
||
|
let path = `M${initialX},${initialY}`;
|
||
|
|
||
|
// Loop through all arcs to construct the 'A' commands for the SVG path
|
||
|
for (const {leftPoint, rightPoint, arcPoint} of arcs) {
|
||
|
// Approximate radius through heuristic, as determining the true
|
||
|
// radius from the circle formed by the three points proved numerically
|
||
|
// unstable.
|
||
|
const radius = calculateDistance(leftPoint, arcPoint).toFixed(2);
|
||
|
|
||
|
const endPointX = rightPoint.x.toFixed(2);
|
||
|
const endPointY = rightPoint.y.toFixed(2);
|
||
|
|
||
|
path += `A${radius},${radius} 0 0, 1 ${endPointX},${endPointY}`;
|
||
|
}
|
||
|
|
||
|
// Close the SVG path with 'Z'
|
||
|
path += ' Z';
|
||
|
return path;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Renders a cloud shape on the SVG canvas. It uses a predefined SVG path
|
||
|
* for the cloud shape, which is scaled to the dimensions of the instance.
|
||
|
* @return {G} An SVG group element (`<g>`)
|
||
|
* that contains the cloud path and label.
|
||
|
*/
|
||
|
draw() {
|
||
|
const points = Cloud.cloudSvgPath(
|
||
|
this.w,
|
||
|
this.h + this.growY,
|
||
|
this.id,
|
||
|
this.size);
|
||
|
|
||
|
const cloudGroup = this.shapeGroup;
|
||
|
const cloud = new Path({
|
||
|
'd': points,
|
||
|
'stroke': this.shapeColor,
|
||
|
'stroke-width': this.thickness,
|
||
|
'style': this.dasharray,
|
||
|
});
|
||
|
|
||
|
this.setFill(cloud);
|
||
|
cloudGroup.add(cloud);
|
||
|
this.drawLabel(cloudGroup);
|
||
|
|
||
|
return cloudGroup;
|
||
|
}
|
||
|
}
|