import { z } from "zod"
import type { WidgetGenerator } from "@/widgets/types"
import { CanvasImpl } from "@/widgets/utils/canvas-impl"
import { PADDING } from "@/widgets/utils/constants"
import { CSS_COLOR_PATTERN } from "@/widgets/utils/css-color"
import { abbreviateMonth } from "@/widgets/utils/labels"
import { createHeightSchema, createWidthSchema } from "@/widgets/utils/schemas"
import { estimateWrappedTextDimensions } from "@/widgets/utils/text"
import { theme } from "@/widgets/utils/theme"
const Point = z
.object({
x: z
.number()
.describe(
"Horizontal coordinate relative to figure's local origin. Can be negative. Figure will be auto-positioned within the layout (e.g., -30, 0, 50, 25.5)."
),
y: z
.number()
.describe(
"Vertical coordinate relative to figure's local origin. Can be negative. Positive y is downward (e.g., -20, 0, 40, 15.5)."
)
})
.strict()
const Figure = z
.object({
vertices: z
.array(Point)
.describe(
"Ordered array of vertices defining the polygon. Connect in order, closing back to first. Minimum 3 vertices for a valid polygon (e.g., triangle, square, pentagon)."
),
fillColor: z
.string()
.regex(
CSS_COLOR_PATTERN,
"invalid css color; use hex (#RGB, #RRGGBB, #RRGGBBAA), rgb/rgba(), hsl/hsla(), or a common named color"
)
.describe(
"Hex-only fill color for the polygon interior (e.g., '#E8F4FD', 'transparent' for outline only, '#FFC8004D' for ~30% alpha)."
),
strokeColor: z
.string()
.regex(
CSS_COLOR_PATTERN,
"invalid css color; use hex (#RGB, #RRGGBB, #RRGGBBAA), rgb/rgba(), hsl/hsla(), or a common named color"
)
.describe(
"Hex-only color for the polygon's border (e.g., '#000000', '#333333', '#00008B'). Set to 'transparent' to hide the outline."
),
strokeWidth: z
.number()
.min(0)
.describe(
"Width of the polygon's border in pixels (e.g., 2 for standard, 3 for bold, 1 for thin). Use 0 for no visible border."
),
sideLabels: z
.array(
z
.string()
.nullable()
.transform((val) =>
val === "null" || val === "NULL" || val === "" ? null : val
)
)
.describe(
"Labels for each edge of the polygon. Array length should match vertex count. First label is for edge from vertex[0] to vertex[1]. Null for no label on that edge."
),
sideLabelOffset: z
.number()
.describe(
"Distance in pixels from edge to place side labels. Positive places outside, negative inside (e.g., 15, -10, 20). Applies to all side labels."
),
figureLabel: z
.object({
text: z
.string()
.describe(
"Main label for the entire figure (e.g., 'Figure A', 'Original', 'Square', '64 cm²'). Can include math notation or symbols."
),
position: z
.enum(["top", "bottom", "left", "right", "center"])
.describe(
"Where to place the label relative to the figure. 'center' places inside the polygon, others place outside."
),
offset: z
.number()
.describe(
"Additional spacing in pixels from the figure's edge or center (e.g., 10, 20, 5). For 'center', this has no effect."
)
})
.strict()
.describe("Configuration for the figure's main identifying label.")
})
.strict()
export const FigureComparisonDiagramPropsSchema = z
.object({
type: z
.literal("figureComparisonDiagram")
.describe(
"Identifies this as a figure comparison diagram for displaying multiple polygons side by side."
),
width: createWidthSchema(),
height: createHeightSchema(),
figures: z
.array(Figure)
.describe(
"Array of polygonal figures to display. Can show different shapes or same shape with different properties. Order determines left-to-right or top-to-bottom placement."
),
layout: z
.enum(["horizontal", "vertical"])
.describe(
"Arrangement direction. 'horizontal' places figures left to right. 'vertical' stacks figures top to bottom. Choose based on figure count and aspect ratios."
),
spacing: z
.number()
.min(0)
.describe(
"Gap between figures in pixels (e.g., 50, 80, 30). Provides visual separation. Larger spacing prevents label overlap."
)
})
.strict()
.describe(
"Creates a comparison view of multiple polygonal figures with comprehensive labeling options. Perfect for showing transformations, comparing shapes, demonstrating congruence/similarity, or analyzing different polygons. Each figure can have different styling and complete edge/vertex labeling."
)
export type FigureComparisonDiagramProps = z.infer<
typeof FigureComparisonDiagramPropsSchema
>
/**
* Generates diagrams for comparing multiple independent geometric figures.
* Perfect for scale copies, similarity problems, and geometric comparisons.
*/
export const generateFigureComparisonDiagram: WidgetGenerator<
typeof FigureComparisonDiagramPropsSchema
> = async (data) => {
const { width, height, figures, layout, spacing } = data
if (figures.length === 0) return ``
const canvas = new CanvasImpl({
chartArea: { left: 0, top: 0, width, height },
fontPxDefault: 12,
lineHeightDefault: 1.2
})
// Collect geometry in raw coordinates first, then fit-to-box scale and render
type Point = { x: number; y: number }
type FigureGeometry = {
vertices: Point[]
sideLabels: Array<{
x: number
y: number
text: string
color: string
edgeFrom: { x: number; y: number }
edgeTo: { x: number; y: number }
}>
figureLabel: { x: number; y: number; text: string; color: string }
fillColor: string
strokeColor: string
strokeWidth: number
}
const figureGeometries: FigureGeometry[] = []
const allPoints: Point[] = []
// Calculate bounding boxes for each figure in raw coordinates
const figureBounds = figures.map((figure) => {
const xs = figure.vertices.map((v) => v.x)
const ys = figure.vertices.map((v) => v.y)
return {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.min(...ys),
maxY: Math.max(...ys),
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys)
}
})
// Calculate total dimensions needed in raw coordinates (without spacing)
const maxFigureWidth = Math.max(...figureBounds.map((b) => b.width))
const maxFigureHeight = Math.max(...figureBounds.map((b) => b.height))
let totalContentWidth: number
let totalContentHeight: number
if (layout === "horizontal") {
totalContentWidth = figureBounds.reduce((sum, b) => sum + b.width, 0)
totalContentHeight = maxFigureHeight
} else {
totalContentWidth = maxFigureWidth
totalContentHeight = figureBounds.reduce((sum, b) => sum + b.height, 0)
}
// Position figures in raw coordinate space (centered around 0,0, no spacing yet)
const startX = -totalContentWidth / 2
const startY = -totalContentHeight / 2
let currentX = startX
let currentY = startY
for (let i = 0; i < figures.length; i++) {
const figure = figures[i]
const bounds = figureBounds[i]
if (!figure || !bounds) continue
// Calculate offset to position this figure in raw coordinates
const offsetX = currentX - bounds.minX
const offsetY = currentY - bounds.minY
// Collect geometry for this figure
const figureGeometry = collectFigureGeometry(figure, offsetX, offsetY)
figureGeometries.push(figureGeometry)
// Add all points to the global collection for fit calculation
allPoints.push(...figureGeometry.vertices)
allPoints.push(
...figureGeometry.sideLabels.map((label) => ({ x: label.x, y: label.y }))
)
allPoints.push({
x: figureGeometry.figureLabel.x,
y: figureGeometry.figureLabel.y
})
// Move to next position in raw coordinates (no spacing)
if (layout === "horizontal") {
currentX += bounds.width
} else {
currentY += bounds.height
}
}
// Compute fit transform from all collected points
const { project } = computeFit(allPoints, width, height)
// Calculate spacing offsets in screen coordinates
const spacingOffsets: Array<{ x: number; y: number }> = []
let cumulativeOffsetX = 0
let cumulativeOffsetY = 0
for (let i = 0; i < figureGeometries.length; i++) {
spacingOffsets.push({ x: cumulativeOffsetX, y: cumulativeOffsetY })
if (i < figureGeometries.length - 1) {
// Don't add spacing after the last figure
if (layout === "horizontal") {
cumulativeOffsetX += spacing
} else {
cumulativeOffsetY += spacing
}
}
}
// Track all placed labels to prevent overlaps between side labels and figure labels
const placedLabelRects: Array<{
x: number
y: number
width: number
height: number
}> = []
// Render all figures with spacing applied in screen space
for (let i = 0; i < figureGeometries.length; i++) {
const figureGeom = figureGeometries[i]
const spacingOffset = spacingOffsets[i]
const figure = figures[i]
if (!figureGeom || !spacingOffset || !figure) continue
// Draw the figure shape with spacing offset
const transformedVertices = figureGeom.vertices.map((v) => {
const projected = project(v)
return {
x: projected.x + spacingOffset.x,
y: projected.y + spacingOffset.y
}
})
canvas.drawPolygon(transformedVertices, {
fill: figureGeom.fillColor,
stroke: figureGeom.strokeColor,
strokeWidth: figureGeom.strokeWidth // Keep original stroke width (screen space)
})
// Draw side labels with spacing offset, nudging outward to avoid line collisions
for (const sideLabel of figureGeom.sideLabels) {
const projectedFrom = project({
x: sideLabel.edgeFrom.x,
y: sideLabel.edgeFrom.y
})
const projectedTo = project({
x: sideLabel.edgeTo.x,
y: sideLabel.edgeTo.y
})
const labelFontPx = 14
const labelLineHeight = 1.2
const { maxWidth: textWidthPx, height: textHeightPx } =
estimateWrappedTextDimensions(
sideLabel.text,
Number.POSITIVE_INFINITY,
labelFontPx,
labelLineHeight
)
// Compute outward normal in screen space using polygon center
const edgeDx = projectedTo.x - projectedFrom.x
const edgeDy = projectedTo.y - projectedFrom.y
const edgeLen = Math.sqrt(edgeDx * edgeDx + edgeDy * edgeDy)
let normalX = 0
let normalY = 0
if (edgeLen > 0) {
normalX = -edgeDy / edgeLen
normalY = edgeDx / edgeLen
}
const polyCenterX =
transformedVertices.reduce((sum, v) => sum + v.x, 0) /
transformedVertices.length
const polyCenterY =
transformedVertices.reduce((sum, v) => sum + v.y, 0) /
transformedVertices.length
// Use edge midpoint for initial candidate placement
const midX = (projectedFrom.x + projectedTo.x) / 2
const midY = (projectedFrom.y + projectedTo.y) / 2
const toCenterX = polyCenterX - midX
const toCenterY = polyCenterY - midY
const dot = normalX * toCenterX + normalY * toCenterY
if (dot > 0) {
normalX = -normalX
normalY = -normalY
}
// Build candidates: outward and inward from the edge midpoint, then choose best by minimal nudges
// midX/midY already defined above
const halfW = textWidthPx / 2
const halfH = textHeightPx / 2
// Capture narrowed values for use inside the local function (avoids optional narrowing loss)
const offsetDistance = figure.sideLabelOffset
const spacingX = spacingOffset.x
const spacingY = spacingOffset.y
function placeFor(dirX: number, dirY: number) {
const startX = midX + dirX * offsetDistance + spacingX
const startY = midY + dirY * offsetDistance + spacingY
let px = startX
let py = startY
let iterations = 0
const maxIterations = 60
const stepPx = 2
while (
polygonIntersectsRect(transformedVertices, {
x: px - halfW,
y: py - halfH,
width: textWidthPx,
height: textHeightPx,
pad: 1
}) &&
iterations < maxIterations
) {
px += dirX * stepPx
py += dirY * stepPx
iterations++
}
return { x: px, y: py, iterations }
}
const outward = placeFor(normalX, normalY)
const inward = placeFor(-normalX, -normalY)
// Choose candidate with fewer iterations; tie-break by further distance from polygon (more iterations not necessarily better, so prefer the one that first cleared)
const chosen = inward.iterations < outward.iterations ? inward : outward
canvas.drawText({
x: chosen.x,
y: chosen.y,
text: sideLabel.text,
fill: sideLabel.color,
anchor: "middle",
dominantBaseline: "middle",
fontPx: labelFontPx,
fontWeight: theme.font.weight.bold
})
// Track this side label's bounding box for figure label collision detection
placedLabelRects.push({
x: chosen.x - textWidthPx / 2,
y: chosen.y - textHeightPx / 2,
width: textWidthPx,
height: textHeightPx
})
}
// Calculate figure label position based on final transformed bounds
const finalBounds = {
minX: Math.min(...transformedVertices.map((v) => v.x)),
maxX: Math.max(...transformedVertices.map((v) => v.x)),
minY: Math.min(...transformedVertices.map((v) => v.y)),
maxY: Math.max(...transformedVertices.map((v) => v.y))
}
const centerX = (finalBounds.minX + finalBounds.maxX) / 2
const centerY = (finalBounds.minY + finalBounds.maxY) / 2
let labelX = centerX
let labelY = centerY
// Add clearance for external side labels to avoid overlap with the figure label.
// This pushes the figure label further out if side labels are present and positioned externally.
const hasExternalSideLabels =
figure.sideLabels.some((l) => typeof l === "string" && l.trim() !== "") &&
figure.sideLabelOffset > 0
const clearance = hasExternalSideLabels ? figure.sideLabelOffset + 20 : 0 // Increased buffer to 20px to account for font sizes.
switch (figure.figureLabel.position) {
case "top":
labelY = finalBounds.minY - figure.figureLabel.offset - clearance
break
case "bottom":
labelY = finalBounds.maxY + figure.figureLabel.offset + clearance
break
case "left":
labelX = finalBounds.minX - figure.figureLabel.offset - clearance
break
case "right":
labelX = finalBounds.maxX + figure.figureLabel.offset + clearance
break
default:
// Already set to center
break
}
// Nudge figure label outward if overlapping the polygon edges
{
const figureFontPx = 16
const figureLineHeight = 1.2
const { maxWidth: figTextW, height: figTextH } =
estimateWrappedTextDimensions(
figureGeom.figureLabel.text,
Number.POSITIVE_INFINITY,
figureFontPx,
figureLineHeight
)
let flX = labelX
let flY = labelY
const halfW = figTextW / 2
const halfH = figTextH / 2
// Determine outward direction based on label position
let dirX = 0
let dirY = 0
if (figure.figureLabel.position === "top") {
dirY = -1
} else if (figure.figureLabel.position === "bottom") {
dirY = 1
} else if (figure.figureLabel.position === "left") {
dirX = -1
} else if (figure.figureLabel.position === "right") {
dirX = 1
} else {
// center: use vector from polygon center to label
const vx = flX - centerX
const vy = flY - centerY
const vlen = Math.sqrt(vx * vx + vy * vy)
if (vlen > 0) {
dirX = vx / vlen
dirY = vy / vlen
}
}
let iter = 0
const maxIter = 60
const step = 2
// Check collision against both polygon edges and placed side labels
function hasCollision(x: number, y: number): boolean {
const figureRect = {
x: x - halfW,
y: y - halfH,
width: figTextW,
height: figTextH
}
// Check collision with polygon edges
if (
polygonIntersectsRect(transformedVertices, { ...figureRect, pad: 1 })
) {
return true
}
// Check collision with placed side labels
for (const placedRect of placedLabelRects) {
if (rectanglesOverlap(figureRect, placedRect)) {
return true
}
}
return false
}
while (hasCollision(flX, flY) && iter < maxIter) {
flX += dirX * step
flY += dirY * step
iter++
}
canvas.drawText({
x: flX,
y: flY,
text: figureGeom.figureLabel.text,
fill: figureGeom.figureLabel.color,
anchor: "middle",
dominantBaseline: "middle",
fontPx: figureFontPx,
fontWeight: theme.font.weight.bold
})
}
}
// Finalize the canvas and construct the root SVG element
const {
svgBody,
vbMinX,
vbMinY,
width: finalWidth,
height: finalHeight
} = canvas.finalize(PADDING)
return ``
}
/**
* Helper: compute fit transform for all collected points
*/
function computeFit(
allPoints: Array<{ x: number; y: number }>,
width: number,
height: number
) {
if (allPoints.length === 0) {
return {
scale: 1,
offsetX: 0,
offsetY: 0,
project: (p: { x: number; y: number }) => p
}
}
const minX = Math.min(...allPoints.map((p) => p.x))
const maxX = Math.max(...allPoints.map((p) => p.x))
const minY = Math.min(...allPoints.map((p) => p.y))
const maxY = Math.max(...allPoints.map((p) => p.y))
const rawW = maxX - minX
const rawH = maxY - minY
const scale = Math.min(
(width - 2 * PADDING) / (rawW || 1),
(height - 2 * PADDING) / (rawH || 1)
)
const offsetX = (width - scale * rawW) / 2 - scale * minX
const offsetY = (height - scale * rawH) / 2 - scale * minY
const project = (p: { x: number; y: number }) => ({
x: offsetX + scale * p.x,
y: offsetY + scale * p.y
})
return { scale, offsetX, offsetY, project }
}
/**
* Collects geometry for a single figure in raw coordinates
*/
function collectFigureGeometry(
figure: z.infer,
offsetX: number,
offsetY: number
): {
vertices: Array<{ x: number; y: number }>
sideLabels: Array<{
x: number
y: number
text: string
color: string
edgeFrom: { x: number; y: number }
edgeTo: { x: number; y: number }
}>
figureLabel: { x: number; y: number; text: string; color: string }
fillColor: string
strokeColor: string
strokeWidth: number
} {
// Transform vertices to raw coordinates
const transformedVertices = figure.vertices.map((v) => ({
x: v.x + offsetX,
y: v.y + offsetY
}))
// Collect side labels
const sideLabels: Array<{
x: number
y: number
text: string
color: string
edgeFrom: { x: number; y: number }
edgeTo: { x: number; y: number }
}> = []
for (
let i = 0;
i < Math.min(figure.sideLabels.length, transformedVertices.length);
i++
) {
const rawLabel = figure.sideLabels[i]
if (!rawLabel) continue
const label = abbreviateMonth(rawLabel)
const from = transformedVertices[i]
const to = transformedVertices[(i + 1) % transformedVertices.length]
if (!from || !to) continue
// Calculate midpoint
const midX = (from.x + to.x) / 2
const midY = (from.y + to.y) / 2
// Calculate perpendicular offset for label placement
const dx = to.x - from.x
const dy = to.y - from.y
const length = Math.sqrt(dx * dx + dy * dy)
const normalX = -dy / length
const normalY = dx / length
// Determine outward direction
const centerX =
transformedVertices.reduce((sum, v) => sum + v.x, 0) /
transformedVertices.length
const centerY =
transformedVertices.reduce((sum, v) => sum + v.y, 0) /
transformedVertices.length
const toCenterX = centerX - midX
const toCenterY = centerY - midY
const dotProduct = normalX * toCenterX + normalY * toCenterY
// Flip normal if it points toward center
const outwardNormalX = dotProduct > 0 ? -normalX : normalX
const outwardNormalY = dotProduct > 0 ? -normalY : normalY
const labelX = midX + outwardNormalX * figure.sideLabelOffset
const labelY = midY + outwardNormalY * figure.sideLabelOffset
sideLabels.push({
x: labelX,
y: labelY,
text: label,
color: figure.strokeColor,
edgeFrom: { x: from.x, y: from.y },
edgeTo: { x: to.x, y: to.y }
})
}
// Calculate figure label position
const bounds = {
minX: Math.min(...transformedVertices.map((v) => v.x)),
maxX: Math.max(...transformedVertices.map((v) => v.x)),
minY: Math.min(...transformedVertices.map((v) => v.y)),
maxY: Math.max(...transformedVertices.map((v) => v.y))
}
const centerX = (bounds.minX + bounds.maxX) / 2
const centerY = (bounds.minY + bounds.maxY) / 2
let labelX = centerX
let labelY = centerY
// Add clearance for external side labels to avoid overlap with the figure label.
// This pushes the figure label further out if side labels are present and positioned externally.
const hasExternalSideLabels =
figure.sideLabels.some((l) => typeof l === "string" && l.trim() !== "") &&
figure.sideLabelOffset > 0
const clearance = hasExternalSideLabels ? figure.sideLabelOffset + 20 : 0 // Increased buffer to 20px to account for font sizes.
switch (figure.figureLabel.position) {
case "top":
labelY = bounds.minY - figure.figureLabel.offset - clearance
break
case "bottom":
labelY = bounds.maxY + figure.figureLabel.offset + clearance
break
case "left":
labelX = bounds.minX - figure.figureLabel.offset - clearance
break
case "right":
labelX = bounds.maxX + figure.figureLabel.offset + clearance
break
default:
// Already set to center
break
}
const labelText = abbreviateMonth(figure.figureLabel.text)
return {
vertices: transformedVertices,
sideLabels,
figureLabel: {
x: labelX,
y: labelY,
text: labelText,
color: figure.strokeColor
},
fillColor: figure.fillColor,
strokeColor: figure.strokeColor,
strokeWidth: figure.strokeWidth
}
}
/**
* Geometry helpers for collision checks in screen space
*/
function segmentIntersectsRect(
A: { x: number; y: number },
B: { x: number; y: number },
rect: { x: number; y: number; width: number; height: number; pad?: number }
): boolean {
const pad = rect.pad ?? 0
const rx = rect.x - pad
const ry = rect.y - pad
const rw = rect.width + 2 * pad
const rh = rect.height + 2 * pad
// Quick reject if both points are on one outside side without crossing
const minX = Math.min(A.x, B.x)
const maxX = Math.max(A.x, B.x)
const minY = Math.min(A.y, B.y)
const maxY = Math.max(A.y, B.y)
if (maxX < rx || minX > rx + rw || maxY < ry || minY > ry + rh) {
// Bounding boxes don't overlap
// Might still intersect if the segment passes through the rectangle area in a narrow way, but
// since the segment bbox is fully outside the expanded rect bbox, it's safe to return false
return false
}
// If either endpoint inside the rect
if (
pointInRect(A.x, A.y, rx, ry, rw, rh) ||
pointInRect(B.x, B.y, rx, ry, rw, rh)
)
return true
// Check intersection with each rectangle side
const r1 = { x: rx, y: ry }
const r2 = { x: rx + rw, y: ry }
const r3 = { x: rx + rw, y: ry + rh }
const r4 = { x: rx, y: ry + rh }
if (segmentsIntersect(A, B, r1, r2)) return true
if (segmentsIntersect(A, B, r2, r3)) return true
if (segmentsIntersect(A, B, r3, r4)) return true
if (segmentsIntersect(A, B, r4, r1)) return true
return false
}
function polygonIntersectsRect(
poly: Array<{ x: number; y: number }>,
rect: { x: number; y: number; width: number; height: number; pad?: number }
): boolean {
if (poly.length < 2) return false
const pad = rect.pad ?? 0
const rx = rect.x - pad
const ry = rect.y - pad
const rw = rect.width + 2 * pad
const rh = rect.height + 2 * pad
// Any polygon edge intersect the rect
for (let i = 0; i < poly.length; i++) {
const a = poly[i]
const b = poly[(i + 1) % poly.length]
if (!a || !b) continue
if (segmentIntersectsRect(a, b, { x: rx, y: ry, width: rw, height: rh }))
return true
}
// Any polygon vertex inside rect
for (const p of poly) {
if (pointInRect(p.x, p.y, rx, ry, rw, rh)) return true
}
// Rect center inside polygon (handles rect completely inside polygon with no edge intersections)
const cx = rx + rw / 2
const cy = ry + rh / 2
if (pointInPolygon({ x: cx, y: cy }, poly)) return true
return false
}
function pointInRect(
x: number,
y: number,
rx: number,
ry: number,
rw: number,
rh: number
): boolean {
return x >= rx && x <= rx + rw && y >= ry && y <= ry + rh
}
function segmentsIntersect(
p1: { x: number; y: number },
p2: { x: number; y: number },
p3: { x: number; y: number },
p4: { x: number; y: number }
): boolean {
function orientation(
a: { x: number; y: number },
b: { x: number; y: number },
c: { x: number; y: number }
): number {
const val = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y)
if (val > 0) return 1
if (val < 0) return -1
return 0
}
function onSegment(
a: { x: number; y: number },
b: { x: number; y: number },
c: { x: number; y: number }
): boolean {
return (
Math.min(a.x, c.x) <= b.x &&
b.x <= Math.max(a.x, c.x) &&
Math.min(a.y, c.y) <= b.y &&
b.y <= Math.max(a.y, c.y)
)
}
const o1 = orientation(p1, p2, p3)
const o2 = orientation(p1, p2, p4)
const o3 = orientation(p3, p4, p1)
const o4 = orientation(p3, p4, p2)
if (o1 !== o2 && o3 !== o4) return true
if (o1 === 0 && onSegment(p1, p3, p2)) return true
if (o2 === 0 && onSegment(p1, p4, p2)) return true
if (o3 === 0 && onSegment(p3, p1, p4)) return true
if (o4 === 0 && onSegment(p3, p2, p4)) return true
return false
}
function pointInPolygon(
point: { x: number; y: number },
polygon: Array<{ x: number; y: number }>
): boolean {
if (polygon.length < 3) return false
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const pi = polygon[i]
const pj = polygon[j]
if (!pi || !pj) continue
const xi = pi.x
const yi = pi.y
const xj = pj.x
const yj = pj.y
const intersect =
yi > point.y !== yj > point.y &&
point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi
if (intersect) inside = !inside
}
return inside
}
function rectanglesOverlap(
rect1: { x: number; y: number; width: number; height: number },
rect2: { x: number; y: number; width: number; height: number }
): boolean {
return !(
rect1.x + rect1.width <= rect2.x ||
rect2.x + rect2.width <= rect1.x ||
rect1.y + rect1.height <= rect2.y ||
rect2.y + rect2.height <= rect1.y
)
}