import { z } from "zod"
import type { WidgetGenerator } from "@/widgets/types"
import { CanvasImpl } from "@/widgets/utils/canvas-impl"
import { AXIS_VIEWBOX_PADDING } from "@/widgets/utils/constants"
import { setupCoordinatePlaneBaseV2 } from "@/widgets/utils/coordinate-plane-utils"
import { CSS_COLOR_PATTERN } from "@/widgets/utils/css-color"
import { createHeightSchema, createWidthSchema } from "@/widgets/utils/schemas"
import { theme } from "@/widgets/utils/theme"
// Factory helpers to avoid schema reuse and $ref generation
function createPointSchema() {
return z.object({
x: z
.number()
.describe("The x-coordinate of the point in an arbitrary data space."),
y: z
.number()
.describe("The y-coordinate of the point in an arbitrary data space.")
})
}
function createSegmentSchema() {
return z.object({
points: z
.array(createPointSchema())
.describe(
"An array of {x, y} points that define this segment of the curve."
),
color: z
.string()
.regex(CSS_COLOR_PATTERN, "invalid css color")
.describe("The color of this line segment."),
label: z
.string()
.describe(
"The text label for this segment to be displayed in the legend."
)
})
}
export const PopulationChangeEventGraphPropsSchema = z
.object({
type: z.literal("populationChangeEventGraph"),
width: createWidthSchema(),
height: createHeightSchema(),
xAxisLabel: z
.string()
.describe("The label for the horizontal axis (e.g., 'Time')."),
yAxisLabel: z
.string()
.describe(
"The label for the vertical axis (e.g., 'Deer population size')."
),
xAxisMin: z
.number()
.describe(
"The minimum value for the x-axis. This should typically be 0 for time-based data."
),
xAxisMax: z
.number()
.describe(
"The maximum value for the x-axis. Set this to accommodate both before and after segments (e.g., 10 if before ends at 5 and after extends to 10)."
),
yAxisMin: z
.number()
.describe(
"The minimum value for the y-axis. CRITICAL: Keep this consistent across all related graphs for meaningful comparison."
),
yAxisMax: z
.number()
.describe(
"The maximum value for the y-axis. CRITICAL: Keep this consistent across all related graphs for meaningful comparison. Choose a value that accommodates all data points with some padding."
),
beforeSegment: createSegmentSchema().describe(
"The data and style for the 'before' period, drawn as a solid line."
),
afterSegment: createSegmentSchema().describe(
"The data and style for the 'after' period, drawn as a dashed line."
),
showLegend: z
.boolean()
.describe("If true, a legend is displayed to identify the line segments.")
})
.strict()
.describe(
"Creates a conceptual graph showing a 'before' and 'after' scenario, typically for population changes over time. Renders a solid line followed by a dashed line, with a legend. IMPORTANT: Always use consistent axis scales across related graphs to enable visual comparison."
)
export type PopulationChangeEventGraphProps = z.infer<
typeof PopulationChangeEventGraphPropsSchema
>
export const generatePopulationChangeEventGraph: WidgetGenerator<
typeof PopulationChangeEventGraphPropsSchema
> = async (props) => {
const {
width,
height,
xAxisLabel,
yAxisLabel,
xAxisMin,
xAxisMax,
yAxisMin,
yAxisMax,
beforeSegment,
afterSegment,
showLegend
} = props
const allPoints = [...beforeSegment.points, ...afterSegment.points]
if (allPoints.length === 0) {
return ``
}
const canvas = new CanvasImpl({
chartArea: { left: 0, top: 0, width, height },
fontPxDefault: 12,
lineHeightDefault: 1.2
})
const baseInfo = setupCoordinatePlaneBaseV2(
{
width,
height,
title: null, // No title for this widget
xAxis: {
xScaleType: "numeric",
label: xAxisLabel,
min: xAxisMin,
max: xAxisMax,
tickInterval: (xAxisMax - xAxisMin) / 5,
showGridLines: false,
showTickLabels: false, // Conceptual, no numeric labels
showTicks: false
},
yAxis: {
label: yAxisLabel,
min: yAxisMin,
max: yAxisMax,
tickInterval: (yAxisMax - yAxisMin) / 5,
showGridLines: false,
showTickLabels: false, // Conceptual, no numeric labels
showTicks: false
}
},
canvas
)
// Axis arrows outside clip
canvas.addDef(
``
)
const yAxisX = baseInfo.chartArea.left
const xAxisY = baseInfo.chartArea.top + baseInfo.chartArea.height
canvas.drawLine(yAxisX, xAxisY, yAxisX, baseInfo.chartArea.top, {
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.thick,
markerEnd: "url(#graph-arrow)"
})
canvas.drawLine(
yAxisX,
xAxisY,
baseInfo.chartArea.left + baseInfo.chartArea.width,
xAxisY,
{
stroke: theme.colors.axis,
strokeWidth: theme.stroke.width.thick,
markerEnd: "url(#graph-arrow)"
}
)
// Curves using Canvas API
if (beforeSegment.points.length > 0) {
const beforePoints = beforeSegment.points.map((p) => ({
x: baseInfo.toSvgX(p.x),
y: baseInfo.toSvgY(p.y)
}))
canvas.drawPolyline(beforePoints, {
stroke: beforeSegment.color,
strokeWidth: theme.stroke.width.xxthick,
strokeLinejoin: "round",
strokeLinecap: "round"
})
}
if (afterSegment.points.length > 0) {
const afterPoints = afterSegment.points.map((p) => ({
x: baseInfo.toSvgX(p.x),
y: baseInfo.toSvgY(p.y)
}))
canvas.drawPolyline(afterPoints, {
stroke: afterSegment.color,
strokeWidth: theme.stroke.width.xxthick,
dash: theme.stroke.dasharray.dashedLong,
strokeLinejoin: "round",
strokeLinecap: "round"
})
}
// Legend content (move to right side, outside clip)
if (showLegend) {
const legendItems = [
{ label: beforeSegment.label, color: beforeSegment.color, dashed: false },
{ label: afterSegment.label, color: afterSegment.color, dashed: true }
]
const legendLineLength = 30
const legendGapX = 8
const legendItemHeight = 18
const legendStartX = baseInfo.chartArea.left + baseInfo.chartArea.width + 15
const legendStartY = baseInfo.chartArea.top + 10
for (const [i, item] of legendItems.entries()) {
const y = legendStartY + i * legendItemHeight
const textY = y + 5
const x1 = legendStartX
const x2 = legendStartX + legendLineLength
const textX = x2 + legendGapX
// Drawing with Canvas calls below
canvas.drawLine(x1, y, x2, y, {
stroke: item.color,
strokeWidth: theme.stroke.width.xxthick,
dash: item.dashed ? "8 6" : undefined
})
canvas.drawText({
x: textX,
y: textY,
text: item.label,
anchor: "start"
})
}
}
// NEW: Finalize the canvas and construct the root SVG element
const {
svgBody,
vbMinX,
vbMinY,
width: finalWidth,
height: finalHeight
} = canvas.finalize(AXIS_VIEWBOX_PADDING)
return ``
}