ChartRenderer.java

/* ========================================================================
 * PlantUML : a free UML diagram generator
 * ========================================================================
 *
 * (C) Copyright 2009-2024, Arnaud Roques
 *
 * Project Info:  https://plantuml.com
 *
 * If you like this project or if you find it useful, you can support us at:
 *
 * https://plantuml.com/patreon (only 1$ per month!)
 * https://plantuml.com/paypal
 *
 * This file is part of PlantUML.
 *
 * PlantUML is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * PlantUML distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
 * License for more details.
 *
 * You should have received a copy of the GNU General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
 * USA.
 *
 *
 * Original Author:  David Fyfe
 *
 */
package net.sourceforge.plantuml.chart;

import java.util.List;
import java.util.Map;

import net.sourceforge.plantuml.klimt.UStroke;
import net.sourceforge.plantuml.klimt.UTranslate;
import net.sourceforge.plantuml.klimt.color.HColor;
import net.sourceforge.plantuml.klimt.color.HColors;
import net.sourceforge.plantuml.klimt.creole.Display;
import net.sourceforge.plantuml.klimt.drawing.UGraphic;
import net.sourceforge.plantuml.klimt.font.FontConfiguration;
import net.sourceforge.plantuml.klimt.font.StringBounder;
import net.sourceforge.plantuml.klimt.font.UFont;
import net.sourceforge.plantuml.klimt.geom.HorizontalAlignment;
import net.sourceforge.plantuml.klimt.geom.XDimension2D;
import net.sourceforge.plantuml.klimt.shape.TextBlock;
import net.sourceforge.plantuml.klimt.shape.ULine;
import net.sourceforge.plantuml.klimt.shape.UText;
import net.sourceforge.plantuml.style.ISkinParam;
import net.sourceforge.plantuml.style.MergeStrategy;
import net.sourceforge.plantuml.style.PName;
import net.sourceforge.plantuml.style.SName;
import net.sourceforge.plantuml.style.Style;
import net.sourceforge.plantuml.style.StyleSignatureBasic;

public class ChartRenderer {

	private final ISkinParam skinParam;
	private final List<String> xAxisLabels;
	private final String xAxisTitle;
	private final Integer xAxisTickSpacing;
	private final ChartAxis.LabelPosition xAxisLabelPosition;
	private final List<ChartSeries> series;
	private final ChartAxis xAxis;
	private final ChartAxis yAxis;
	private final ChartAxis y2Axis;
	private final ChartDiagram.LegendPosition legendPosition;
	private final ChartDiagram.GridMode xGridMode;
	private final ChartDiagram.GridMode yGridMode;
	private final ChartDiagram.StackMode stackMode;
	private final ChartDiagram.Orientation orientation;
	private final List<ChartAnnotation> annotations;

	// Layout constants
	private static final double MARGIN = 20;
	private static final double AXIS_LABEL_SPACE = 40;
	private static final double TITLE_SPACE = 30;
	private static final double TICK_SIZE = 5;
	private static final double LEGEND_MARGIN = 10;
	private static final double LEGEND_SYMBOL_SIZE = 12;
	private static final double LEGEND_TEXT_SPACING = 5;
	private static final double LEGEND_ITEM_SPACING = 15;

	public ChartRenderer(ISkinParam skinParam, List<String> xAxisLabels, String xAxisTitle, Integer xAxisTickSpacing,
			ChartAxis.LabelPosition xAxisLabelPosition, List<ChartSeries> series, ChartAxis xAxis, ChartAxis yAxis, ChartAxis y2Axis,
			ChartDiagram.LegendPosition legendPosition, ChartDiagram.GridMode xGridMode, ChartDiagram.GridMode yGridMode,
			ChartDiagram.StackMode stackMode, ChartDiagram.Orientation orientation, List<ChartAnnotation> annotations) {
		this.skinParam = skinParam;
		this.orientation = orientation;

		// For horizontal orientation: swap x and y axis interpretation
		// User writes: x-axis "Revenue" 0 --> 100, y-axis [A, B, C]
		// We need: yAxis = numeric (for horizontal bars), xAxisLabels = categories
		// So we interpret user's "x-axis" range as our Y-axis, and "y-axis" labels as our X-axis
		if (orientation == ChartDiagram.Orientation.HORIZONTAL) {
			// Create Y-axis from what user specified as X-axis (numeric range)
			// Note: User's x-axis data comes in as xAxisLabels (categories), but for horizontal
			// they would specify it as numeric range which goes to... wait, that won't work.
			// The user can't specify a numeric range for X-axis, only labels!

			// Actually, keep it simple: just swap the data we received
			this.xAxisLabels = xAxisLabels;
			this.xAxisTitle = xAxisTitle;
			this.xAxisTickSpacing = xAxisTickSpacing;
			this.xAxisLabelPosition = xAxisLabelPosition;
			this.xAxis = xAxis;
			this.yAxis = yAxis;
			this.y2Axis = y2Axis;
			this.xGridMode = xGridMode;
			this.yGridMode = yGridMode;
		} else {
			this.xAxisLabels = xAxisLabels;
			this.xAxisTitle = xAxisTitle;
			this.xAxisTickSpacing = xAxisTickSpacing;
			this.xAxisLabelPosition = xAxisLabelPosition;
			this.xAxis = xAxis;
			this.yAxis = yAxis;
			this.y2Axis = y2Axis;
			this.xGridMode = xGridMode;
			this.yGridMode = yGridMode;
		}

		this.series = series;
		this.legendPosition = legendPosition;
		this.stackMode = stackMode;
		this.annotations = annotations;
	}

	public XDimension2D calculateDimension(StringBounder stringBounder) {
		final XDimension2D legendDim = calculateLegendDimension(stringBounder);
		double width = MARGIN + AXIS_LABEL_SPACE + getPlotWidth() + (y2Axis != null ? AXIS_LABEL_SPACE : 0) + MARGIN;
		double height = MARGIN + TITLE_SPACE + getPlotHeight() + AXIS_LABEL_SPACE + MARGIN;

		// Add space for X-axis title if present
		if (xAxisTitle != null && !xAxisTitle.isEmpty()) {
			height += 20; // Extra space for X-axis title
		}

		// Add space for legend
		if (legendPosition == ChartDiagram.LegendPosition.LEFT || legendPosition == ChartDiagram.LegendPosition.RIGHT) {
			width += legendDim.getWidth() + LEGEND_MARGIN;
		} else if (legendPosition == ChartDiagram.LegendPosition.TOP || legendPosition == ChartDiagram.LegendPosition.BOTTOM) {
			height += legendDim.getHeight() + LEGEND_MARGIN;
		}

		return new XDimension2D(width, height);
	}

	public void drawU(UGraphic ug) {
		final StringBounder stringBounder = ug.getStringBounder();

		// Get style and colors
		final Style style = getStyleSignature().getMergedStyle(skinParam.getCurrentStyleBuilder());
		final HColor lineColor = style.value(PName.LineColor).asColor(skinParam.getIHtmlColorSet());
		final HColor fontColor = style.value(PName.FontColor).asColor(skinParam.getIHtmlColorSet());

		// Apply stroke and color
		ug = ug.apply(lineColor).apply(UStroke.withThickness(1.0));

		// Calculate legend dimensions and adjust layout
		final XDimension2D legendDim = calculateLegendDimension(stringBounder);
		double leftMargin = MARGIN + AXIS_LABEL_SPACE;
		double topMargin = MARGIN + TITLE_SPACE;
		final double plotWidth = getPlotWidth();
		final double plotHeight = getPlotHeight();

		// Adjust margins for legend position
		if (legendPosition == ChartDiagram.LegendPosition.LEFT) {
			leftMargin += legendDim.getWidth() + LEGEND_MARGIN;
		} else if (legendPosition == ChartDiagram.LegendPosition.TOP) {
			topMargin += legendDim.getHeight() + LEGEND_MARGIN;
		}

		// Calculate X-axis position (align with zero if y-axis includes zero)
		double xAxisY = topMargin + plotHeight;
		if (orientation != ChartDiagram.Orientation.HORIZONTAL && yAxis != null) {
			// Check if Y-axis range includes zero
			if (yAxis.getMin() <= 0 && yAxis.getMax() >= 0) {
				// Calculate Y position of zero
				final double zeroRatio = (0 - yAxis.getMin()) / (yAxis.getMax() - yAxis.getMin());
				xAxisY = topMargin + plotHeight * (1.0 - zeroRatio);
			}
		}

		// Calculate Y-axis position (align with zero if x-axis includes zero)
		double yAxisX = leftMargin;
		if (orientation != ChartDiagram.Orientation.HORIZONTAL && xAxis != null) {
			// Check if X-axis range includes zero
			if (xAxis.getMin() <= 0 && xAxis.getMax() >= 0) {
				// Calculate X position of zero
				final double zeroX = xAxis.valueToPixel(0, 0, plotWidth);
				yAxisX = leftMargin + zeroX;
			}
		}

		// Draw axes based on orientation
		if (orientation == ChartDiagram.Orientation.HORIZONTAL) {
			// For horizontal bars: categories on left (vertical), numeric on bottom (horizontal)
			// xAxisLabels = categories (draw vertically on left)
			// yAxis = numeric (draw horizontally at bottom)
			drawXAxis(ug.apply(UTranslate.dx(leftMargin).compose(UTranslate.dy(topMargin))), plotHeight,
					plotHeight, lineColor, fontColor);
			drawYAxisHorizontally(ug.apply(UTranslate.dx(leftMargin).compose(UTranslate.dy(topMargin + plotHeight))), plotWidth,
					plotHeight, yAxis, lineColor, fontColor);
		} else {
			// For vertical bars: categories on bottom (horizontal), numeric on left (vertical)
			// xAxisLabels = categories (draw horizontally at bottom)
			// yAxis = numeric (draw vertically on left)
			drawYAxis(ug.apply(UTranslate.dx(yAxisX).compose(UTranslate.dy(topMargin))), plotHeight, plotWidth, yAxis, true,
					lineColor, fontColor);

			if (y2Axis != null) {
				drawYAxis(ug.apply(UTranslate.dx(leftMargin + plotWidth).compose(UTranslate.dy(topMargin))), plotHeight, plotWidth,
						y2Axis, false, lineColor, fontColor);
			}

			drawXAxis(ug.apply(UTranslate.dx(leftMargin).compose(UTranslate.dy(xAxisY))), plotWidth,
					plotHeight, lineColor, fontColor);
		}

		// Draw grid lines (before series data so series draws on top)
		final UGraphic ugPlot = ug.apply(UTranslate.dx(leftMargin).compose(UTranslate.dy(topMargin)));
		drawGridLines(ugPlot, plotWidth, plotHeight, lineColor, fontColor);

		// Draw series data
		drawSeries(ugPlot, plotWidth, plotHeight);

		// Draw annotations
		drawAnnotations(ugPlot, plotWidth, plotHeight, lineColor, fontColor);

		// Draw legend
		drawLegend(ug, leftMargin, topMargin, plotWidth, plotHeight, lineColor, fontColor);
	}

	private void drawYAxis(UGraphic ug, double height, double width, ChartAxis axis, boolean leftSide, HColor lineColor,
			HColor fontColor) {
		// Get axis-specific style
		final Style axisStyle = getAxisStyleSignature(false)
			.getMergedStyle(skinParam.getCurrentStyleBuilder());

		// Extract styled properties
		final HColor styledLineColor = axisStyle.value(PName.LineColor)
			.asColor(skinParam.getIHtmlColorSet());
		final double lineThickness = axisStyle.value(PName.LineThickness)
			.asDouble();
		final FontConfiguration fontConfig = axisStyle.getFontConfiguration(skinParam.getIHtmlColorSet());

		// Use styled properties for axis line
		final HColor actualLineColor = styledLineColor != null ? styledLineColor : lineColor;
		ug = ug.apply(actualLineColor).apply(UStroke.withThickness(lineThickness));

		// Draw axis line
		ug.draw(ULine.vline(height));

		// Get grid style
		final Style gridStyle = getGridStyleSignature()
			.getMergedStyle(skinParam.getCurrentStyleBuilder());

		// Extract grid properties
		HColor gridColor = gridStyle.value(PName.LineColor)
			.asColor(skinParam.getIHtmlColorSet());
		if (gridColor == null) {
			// Fallback to default
			try {
				gridColor = skinParam.getIHtmlColorSet().getColor("#D0D0D0");
			} catch (Exception e) {
				gridColor = lineColor;
			}
		}
		final double gridThickness = gridStyle.value(PName.LineThickness).asDouble();
		final UStroke gridStroke = UStroke.withThickness(gridThickness);

		// Use custom ticks if defined, otherwise use automatic ticks
		final StringBounder stringBounder = ug.getStringBounder();
		if (axis.hasCustomTicks()) {
			// Draw custom ticks
			for (Map.Entry<Double, String> entry : axis.getCustomTicks().entrySet()) {
				final double value = entry.getKey();
				final String label = entry.getValue();

				// Calculate y position based on value
				final double y = height * (1.0 - (value - axis.getMin()) / (axis.getMax() - axis.getMin()));

				// Skip if outside axis range
				if (y < 0 || y > height)
					continue;

				// Draw grid lines if enabled (horizontal lines for Y axis)
				// Skip if coordinate-pair mode (grid drawn separately in drawGridLines)
				if (leftSide && yGridMode != ChartDiagram.GridMode.OFF && xAxis == null) {
					final ULine gridLine = ULine.hline(width);
					ug.apply(gridColor).apply(gridStroke).apply(UTranslate.dy(y)).draw(gridLine);
				}

				// Draw tick
				if (leftSide)
					ug.apply(UTranslate.dy(y)).draw(ULine.hline(-TICK_SIZE));
				else
					ug.apply(UTranslate.dy(y)).draw(ULine.hline(TICK_SIZE));

				// Draw custom label
				final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), label)
						.create(fontConfig, HorizontalAlignment.RIGHT, skinParam);
				final double textHeight = textBlock.calculateDimension(stringBounder).getHeight();

				if (leftSide) {
					final double textWidth = textBlock.calculateDimension(stringBounder).getWidth();
					textBlock.drawU(ug.apply(UTranslate.dx(-TICK_SIZE - textWidth - 5).compose(UTranslate.dy(y - textHeight / 2))));
				} else {
					textBlock.drawU(ug.apply(UTranslate.dx(TICK_SIZE + 5).compose(UTranslate.dy(y - textHeight / 2))));
				}
			}
		} else if (axis.hasTickSpacing()) {
			// Draw ticks with custom spacing
			final double spacing = axis.getTickSpacing();
			final double range = axis.getMax() - axis.getMin();

			// Calculate starting value (round up to nearest spacing interval)
			double startValue = Math.ceil(axis.getMin() / spacing) * spacing;

			for (double value = startValue; value <= axis.getMax(); value += spacing) {
				// Avoid floating point precision issues
				if (value > axis.getMax() + spacing * 0.01)
					break;

				final double y = height * (1.0 - (value - axis.getMin()) / range);

				// Draw grid lines if enabled (horizontal lines for Y axis)
				// Skip if coordinate-pair mode (grid drawn separately in drawGridLines)
				if (leftSide && yGridMode != ChartDiagram.GridMode.OFF && xAxis == null) {
					final ULine gridLine = ULine.hline(width);
					ug.apply(gridColor).apply(gridStroke).apply(UTranslate.dy(y)).draw(gridLine);
				}

				// Draw tick
				if (leftSide)
					ug.apply(UTranslate.dy(y)).draw(ULine.hline(-TICK_SIZE));
				else
					ug.apply(UTranslate.dy(y)).draw(ULine.hline(TICK_SIZE));

				// Draw label
				final String label = formatValue(value);
				final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), label)
						.create(fontConfig, HorizontalAlignment.RIGHT, skinParam);
				final double textHeight = textBlock.calculateDimension(stringBounder).getHeight();

				if (leftSide) {
					final double textWidth = textBlock.calculateDimension(stringBounder).getWidth();
					textBlock.drawU(ug.apply(UTranslate.dx(-TICK_SIZE - textWidth - 5).compose(UTranslate.dy(y - textHeight / 2))));
				} else {
					textBlock.drawU(ug.apply(UTranslate.dx(TICK_SIZE + 5).compose(UTranslate.dy(y - textHeight / 2))));
				}
			}
		} else {
			// Draw automatic ticks
			final int numTicks = 5;
			for (int i = 0; i <= numTicks; i++) {
				final double y = height * (1.0 - (double) i / numTicks);
				final double value = axis.getMin() + (axis.getMax() - axis.getMin()) * i / numTicks;

				// Draw grid lines if enabled (horizontal lines for Y axis)
				// Skip if coordinate-pair mode (grid drawn separately in drawGridLines)
				if (leftSide && yGridMode != ChartDiagram.GridMode.OFF && xAxis == null) {
					final ULine gridLine = ULine.hline(width);
					ug.apply(gridColor).apply(gridStroke).apply(UTranslate.dy(y)).draw(gridLine);
				}

				// Draw tick
				if (leftSide)
					ug.apply(UTranslate.dy(y)).draw(ULine.hline(-TICK_SIZE));
				else
					ug.apply(UTranslate.dy(y)).draw(ULine.hline(TICK_SIZE));

				// Draw label
				final String label = formatValue(value);
				final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), label)
						.create(fontConfig, HorizontalAlignment.RIGHT, skinParam);
				final double textHeight = textBlock.calculateDimension(stringBounder).getHeight();

				if (leftSide) {
					final double textWidth = textBlock.calculateDimension(stringBounder).getWidth();
					textBlock.drawU(ug.apply(UTranslate.dx(-TICK_SIZE - textWidth - 5).compose(UTranslate.dy(y - textHeight / 2))));
				} else {
					textBlock.drawU(ug.apply(UTranslate.dx(TICK_SIZE + 5).compose(UTranslate.dy(y - textHeight / 2))));
				}
			}
		}

		// Draw axis title
		if (axis.getTitle() != null && !axis.getTitle().isEmpty()) {
			if (axis.getLabelPosition() == ChartAxis.LabelPosition.TOP) {
				// Draw horizontally at top
				drawHorizontalAxisTitle(ug, axis.getTitle(), height, leftSide, fontColor, true, fontConfig);
			} else {
				// Draw vertically (default)
				drawVerticalText(ug, axis.getTitle(), height, leftSide, fontColor, fontConfig);
			}
		}
	}

	private void drawVerticalText(UGraphic ug, String text, double height, boolean leftSide, HColor fontColor,
			FontConfiguration axisFontConfig) {
		// Use the provided font configuration (same as tick labels)
		// Left axis (Y): 90 degrees (reads from bottom to top)
		// Right axis (Y2): 270 degrees (reads from top to bottom)
		final int orientation = leftSide ? 90 : 270;
		final UText utext = UText.build(text, axisFontConfig).withOrientation(orientation);

		// Calculate dimensions of the text using the axis font
		final UFont font = axisFontConfig.getFont();
		final double textWidth = ug.getStringBounder().calculateDimension(font, text).getWidth();
		final double textHeight = ug.getStringBounder().calculateDimension(font, text).getHeight();

		// Position the rotated text centered vertically along the axis
		// When rotated 90°, the baseline is the rotation point
		// The text width becomes the vertical span after rotation
		// To center: baseline should be at (height/2 + textWidth/2) for 90° or (height/2 - textWidth/2) for 270°
		// Add extra spacing (10 pixels) to move labels further from the axis
		final double extraSpacing = 10;
		final double xPos = leftSide ? -AXIS_LABEL_SPACE + textHeight / 2 - extraSpacing : AXIS_LABEL_SPACE - textHeight / 2 + extraSpacing;
		// For 90° (left): baseline at top of text, so position at center + half width to center the text
		// For 270° (right): baseline at bottom of text, so position at center - half width to center the text
		final double yPos = leftSide ? (height / 2 + textWidth / 2) : (height / 2 - textWidth / 2);

		ug.apply(UTranslate.dx(xPos).compose(UTranslate.dy(yPos))).draw(utext);
	}

	private void drawHorizontalAxisTitle(UGraphic ug, String text, double height, boolean leftSide, HColor fontColor,
			boolean isVerticalAxis, FontConfiguration axisFontConfig) {
		// Use the provided font configuration (same as tick labels)
		final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), text)
				.create(axisFontConfig, HorizontalAlignment.CENTER, skinParam);
		final double textWidth = textBlock.calculateDimension(ug.getStringBounder()).getWidth();
		final double textHeight = textBlock.calculateDimension(ug.getStringBounder()).getHeight();

		if (isVerticalAxis) {
			// For vertical axis with label at top
			final double xPos = leftSide ? -AXIS_LABEL_SPACE / 2 - textWidth / 2 : AXIS_LABEL_SPACE / 2 - textWidth / 2;
			final double yPos = -textHeight - 15; // Position higher above the axis
			textBlock.drawU(ug.apply(UTranslate.dx(xPos).compose(UTranslate.dy(yPos))));
		} else {
			// For horizontal axis with label at right
			final double xPos = height + 10; // Position to the right
			final double yPos = -textHeight / 2;
			textBlock.drawU(ug.apply(UTranslate.dx(xPos).compose(UTranslate.dy(yPos))));
		}
	}

	private void drawXAxis(UGraphic ug, double width, double height, HColor lineColor, HColor fontColor) {
		// For horizontal orientation, draw category labels vertically on the left
		if (orientation == ChartDiagram.Orientation.HORIZONTAL) {
			drawCategoriesVerticallyOnLeft(ug, height, lineColor, fontColor);
			return;
		}

		// Get axis-specific style
		final Style axisStyle = getAxisStyleSignature(true)
			.getMergedStyle(skinParam.getCurrentStyleBuilder());

		// Extract styled properties
		final HColor styledLineColor = axisStyle.value(PName.LineColor)
			.asColor(skinParam.getIHtmlColorSet());
		final double lineThickness = axisStyle.value(PName.LineThickness)
			.asDouble();
		final FontConfiguration fontConfig = axisStyle.getFontConfiguration(skinParam.getIHtmlColorSet());

		// Use styled properties for axis line
		final HColor actualLineColor = styledLineColor != null ? styledLineColor : lineColor;
		ug = ug.apply(actualLineColor).apply(UStroke.withThickness(lineThickness));

		// Draw axis line
		ug.draw(ULine.hline(width));

		// Draw labels (only if we have categorical x-axis labels)
		final StringBounder stringBounder = ug.getStringBounder();
		if (!xAxisLabels.isEmpty()) {
			final double categoryWidth = width / xAxisLabels.size();

		// Get grid style
		final Style gridStyle = getGridStyleSignature()
			.getMergedStyle(skinParam.getCurrentStyleBuilder());

		// Extract grid properties
		HColor gridColor = gridStyle.value(PName.LineColor)
			.asColor(skinParam.getIHtmlColorSet());
		if (gridColor == null) {
			// Fallback to default
			try {
				gridColor = skinParam.getIHtmlColorSet().getColor("#D0D0D0");
			} catch (Exception e) {
				gridColor = lineColor;
			}
		}
		final double gridThickness = gridStyle.value(PName.LineThickness).asDouble();
		final UStroke gridStroke = UStroke.withThickness(gridThickness);

		// Determine which labels to show based on spacing
		final int spacing = (xAxisTickSpacing != null && xAxisTickSpacing > 0) ? xAxisTickSpacing : 1;

		// Draw vertical grid lines at category centers spanning the full plot height
		// Grid lines should span from top of plot to bottom of plot
		// The height parameter represents the full plot height
		if (xGridMode != ChartDiagram.GridMode.OFF) {
			// Calculate where the x-axis is positioned within the plot
			// When axis is at bottom (all positive values), draw upward by full height
			// When axis crosses zero, draw both up (to top) and down (to bottom)
			final double distanceToTop;
			final double distanceToBottom;

			if (yAxis != null && yAxis.getMin() <= 0 && yAxis.getMax() >= 0) {
				// Axis crosses zero - calculate distances to top and bottom
				final double zeroRatio = (0 - yAxis.getMin()) / (yAxis.getMax() - yAxis.getMin());
				distanceToTop = height * (1.0 - zeroRatio);
				distanceToBottom = height * zeroRatio;
			} else {
				// Axis is at bottom (all positive) or top (all negative)
				distanceToTop = height;
				distanceToBottom = 0;
			}

			// Draw grid lines at category centers (where data points are positioned)
			for (int i = 0; i < xAxisLabels.size(); i++) {
				final double gridX = (i + 0.5) * categoryWidth;
				// Draw line upward to top of plot
				if (distanceToTop > 0) {
					final ULine gridLineUp = ULine.vline(-distanceToTop);
					ug.apply(gridColor).apply(gridStroke).apply(UTranslate.dx(gridX)).draw(gridLineUp);
				}
				// Draw line downward to bottom of plot
				if (distanceToBottom > 0) {
					final ULine gridLineDown = ULine.vline(distanceToBottom);
					ug.apply(gridColor).apply(gridStroke).apply(UTranslate.dx(gridX)).draw(gridLineDown);
				}
			}
		}

		// Draw ticks and labels at category centers
		for (int i = 0; i < xAxisLabels.size(); i++) {
			final double labelX = (i + 0.5) * categoryWidth; // Center position for labels

			// Only draw tick and label every N positions based on spacing
			if (i % spacing == 0) {
				// Draw tick at label position
				ug.apply(UTranslate.dx(labelX)).draw(ULine.vline(TICK_SIZE));

				// Draw label
				final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), xAxisLabels.get(i))
						.create(fontConfig, HorizontalAlignment.CENTER, skinParam);
				final double textWidth = textBlock.calculateDimension(stringBounder).getWidth();
				textBlock.drawU(ug.apply(UTranslate.dx(labelX - textWidth / 2).compose(UTranslate.dy(TICK_SIZE + 5))));
			}
		}

		// Draw X-axis title if present
		if (xAxisTitle != null && !xAxisTitle.isEmpty()) {
			if (xAxisLabelPosition == ChartAxis.LabelPosition.RIGHT) {
				// Draw at the right end of the axis
				final TextBlock titleBlock = Display.getWithNewlines(skinParam.getPragma(), xAxisTitle)
						.create(fontConfig, HorizontalAlignment.LEFT, skinParam);
				final double textHeight = titleBlock.calculateDimension(stringBounder).getHeight();
				titleBlock.drawU(ug.apply(UTranslate.dx(width + 10).compose(UTranslate.dy(-textHeight / 2))));
			} else {
				// Draw centered below the axis (default)
				final TextBlock titleBlock = Display.getWithNewlines(skinParam.getPragma(), xAxisTitle)
						.create(fontConfig, HorizontalAlignment.CENTER, skinParam);
				final double titleWidth = titleBlock.calculateDimension(stringBounder).getWidth();
				final double titleY = TICK_SIZE + 25; // Position below the labels
				titleBlock.drawU(ug.apply(UTranslate.dx(width / 2 - titleWidth / 2).compose(UTranslate.dy(titleY))));
			}
		}
		} else if (xAxis != null) {
			// Draw numeric x-axis ticks for coordinate-pair mode
			final double range = xAxis.getMax() - xAxis.getMin();
			final double tickInterval;

			if (xAxisTickSpacing != null && xAxisTickSpacing > 0) {
				// Spacing directly specifies the tick interval
				tickInterval = xAxisTickSpacing;
			} else {
				// Default: approximately 10 ticks
				tickInterval = range / 10.0;
			}

			// Find the starting tick value (round down to nearest multiple of tickInterval)
			final double startValue = Math.floor(xAxis.getMin() / tickInterval) * tickInterval;

			// Draw ticks and labels from start to end
			for (double value = startValue; value <= xAxis.getMax() + tickInterval * 0.01; value += tickInterval) {
				// Skip if outside axis range
				if (value < xAxis.getMin() - tickInterval * 0.01 || value > xAxis.getMax() + tickInterval * 0.01)
					continue;

				final double x = xAxis.valueToPixel(value, 0, width);

				// Draw tick mark
				ug.apply(UTranslate.dx(x)).draw(ULine.vline(TICK_SIZE));

				// Draw label
				final String label = formatAxisValue(value);
				final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), label)
						.create(fontConfig, HorizontalAlignment.CENTER, skinParam);
				final double textWidth = textBlock.calculateDimension(stringBounder).getWidth();
				textBlock.drawU(ug.apply(UTranslate.dx(x - textWidth / 2).compose(UTranslate.dy(TICK_SIZE + 5))));
			}

			// Draw X-axis title if present
			if (xAxisTitle != null && !xAxisTitle.isEmpty()) {
				if (xAxisLabelPosition == ChartAxis.LabelPosition.RIGHT) {
					// Draw at the right end of the axis
					final TextBlock titleBlock = Display.getWithNewlines(skinParam.getPragma(), xAxisTitle)
							.create(fontConfig, HorizontalAlignment.LEFT, skinParam);
					final double textHeight = titleBlock.calculateDimension(stringBounder).getHeight();
					titleBlock.drawU(ug.apply(UTranslate.dx(width + 10).compose(UTranslate.dy(-textHeight / 2))));
				} else {
					// Draw centered below the axis (default)
					final TextBlock titleBlock = Display.getWithNewlines(skinParam.getPragma(), xAxisTitle)
							.create(fontConfig, HorizontalAlignment.CENTER, skinParam);
					final double titleWidth = titleBlock.calculateDimension(stringBounder).getWidth();
					final double titleY = TICK_SIZE + 25; // Position below the labels
					titleBlock.drawU(ug.apply(UTranslate.dx(width / 2 - titleWidth / 2).compose(UTranslate.dy(titleY))));
				}
			}
		} // Close the if (!xAxisLabels.isEmpty()) / else if
	}

	private void drawCategoriesVerticallyOnLeft(UGraphic ug, double height, HColor lineColor, HColor fontColor) {
		// Draw vertical axis line on the left
		ug.draw(ULine.vline(height));

		if (xAxisLabels.isEmpty())
			return;

		final UFont font = UFont.sansSerif(10);
		final FontConfiguration fontConfig = FontConfiguration.create(font, fontColor, fontColor, null);
		final double categoryHeight = height / xAxisLabels.size();

		for (int i = 0; i < xAxisLabels.size(); i++) {
			final double y = (i + 0.5) * categoryHeight;

			// Draw tick mark pointing left
			ug.apply(UTranslate.dx(-TICK_SIZE).compose(UTranslate.dy(y))).draw(ULine.hline(TICK_SIZE));

			// Draw label to the left of the tick
			final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), xAxisLabels.get(i))
					.create(fontConfig, HorizontalAlignment.RIGHT, skinParam);
			final double textHeight = textBlock.calculateDimension(ug.getStringBounder()).getHeight();
			final double textWidth = textBlock.calculateDimension(ug.getStringBounder()).getWidth();
			textBlock.drawU(ug.apply(UTranslate.dx(-TICK_SIZE - textWidth - 5).compose(UTranslate.dy(y - textHeight / 2))));
		}
	}

	private void drawYAxisHorizontally(UGraphic ug, double width, double height, ChartAxis axis, HColor lineColor,
			HColor fontColor) {
		// Draw horizontal axis line
		ug.draw(ULine.hline(width));

		// Draw ticks and labels
		final UFont font = UFont.sansSerif(10);
		final FontConfiguration fontConfig = FontConfiguration.create(font, fontColor, fontColor, null);

		// Calculate grid line color (lighter than axis color)
		HColor gridColor = lineColor;
		try {
			gridColor = skinParam.getIHtmlColorSet().getColor("#D0D0D0");
		} catch (Exception e) {
			// Use default line color
		}

		// Use automatic ticks (similar to vertical Y-axis logic)
		final double range = axis.getMax() - axis.getMin();
		final int NUM_TICKS = 5;

		for (int i = 0; i <= NUM_TICKS; i++) {
			final double value = axis.getMin() + (i * range / NUM_TICKS);
			final double x = width * i / NUM_TICKS;

			// Draw tick mark pointing down
			ug.apply(UTranslate.dx(x)).draw(ULine.vline(TICK_SIZE));

			// Draw label below the tick
			final String label = String.format("%.0f", value);
			final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), label)
					.create(fontConfig, HorizontalAlignment.CENTER, skinParam);
			final double textWidth = textBlock.calculateDimension(ug.getStringBounder()).getWidth();
			textBlock.drawU(ug.apply(UTranslate.dx(x - textWidth / 2).compose(UTranslate.dy(TICK_SIZE + 5))));
		}

		// Draw axis title if present
		if (axis.getTitle() != null && !axis.getTitle().isEmpty()) {
			final TextBlock titleBlock = Display.getWithNewlines(skinParam.getPragma(), axis.getTitle())
					.create(fontConfig, HorizontalAlignment.CENTER, skinParam);
			final double titleWidth = titleBlock.calculateDimension(ug.getStringBounder()).getWidth();
			final double titleY = TICK_SIZE + 25;
			titleBlock.drawU(ug.apply(UTranslate.dx(width / 2 - titleWidth / 2).compose(UTranslate.dy(titleY))));
		}
	}

	private void drawGridLines(UGraphic ug, double plotWidth, double plotHeight, HColor lineColor, HColor fontColor) {
		// Draw grid lines for coordinate-pair mode (numeric axes)
		// UGraphic ug is at plot origin (leftMargin, topMargin)

		if (xAxis == null || yAxis == null)
			return; // Only draw grids for coordinate-pair mode

		// Get grid style
		final Style gridStyle = getGridStyleSignature()
			.getMergedStyle(skinParam.getCurrentStyleBuilder());

		// Extract grid properties
		HColor gridColor = gridStyle.value(PName.LineColor)
			.asColor(skinParam.getIHtmlColorSet());
		if (gridColor == null) {
			try {
				gridColor = skinParam.getIHtmlColorSet().getColor("#D0D0D0");
			} catch (Exception e) {
				gridColor = lineColor;
			}
		}
		final double gridThickness = gridStyle.value(PName.LineThickness).asDouble();
		final UStroke gridStroke = UStroke.withThickness(gridThickness);

		// Draw vertical grid lines (h-axis)
		if (xGridMode != ChartDiagram.GridMode.OFF && xAxis != null) {
			final double range = xAxis.getMax() - xAxis.getMin();
			final double tickInterval;

			if (xAxisTickSpacing != null && xAxisTickSpacing > 0) {
				tickInterval = xAxisTickSpacing;
			} else {
				tickInterval = range / 10.0;
			}

			final double startValue = Math.floor(xAxis.getMin() / tickInterval) * tickInterval;

			for (double value = startValue; value <= xAxis.getMax() + tickInterval * 0.01; value += tickInterval) {
				if (value < xAxis.getMin() - tickInterval * 0.01 || value > xAxis.getMax() + tickInterval * 0.01)
					continue;

				final double x = xAxis.valueToPixel(value, 0, plotWidth);
				final ULine gridLine = ULine.vline(plotHeight);
				ug.apply(gridColor).apply(gridStroke).apply(UTranslate.dx(x)).draw(gridLine);
			}
		}

		// Draw horizontal grid lines (v-axis)
		if (yGridMode != ChartDiagram.GridMode.OFF && yAxis != null) {
			final double range = yAxis.getMax() - yAxis.getMin();
			final double tickInterval;

			if (yAxis.hasTickSpacing()) {
				tickInterval = yAxis.getTickSpacing();
			} else {
				tickInterval = range / 10.0;
			}

			final double startValue = Math.ceil(yAxis.getMin() / tickInterval) * tickInterval;

			for (double value = startValue; value <= yAxis.getMax() + tickInterval * 0.01; value += tickInterval) {
				if (value < yAxis.getMin() - tickInterval * 0.01 || value > yAxis.getMax() + tickInterval * 0.01)
					continue;

				final double y = plotHeight * (1.0 - (value - yAxis.getMin()) / (yAxis.getMax() - yAxis.getMin()));
				final ULine gridLine = ULine.hline(plotWidth);
				ug.apply(gridColor).apply(gridStroke).apply(UTranslate.dy(y)).draw(gridLine);
			}
		}
	}

	private void drawSeries(UGraphic ug, double plotWidth, double plotHeight) {
		// Check if we have series to render
		boolean hasCoordinatePairs = !series.isEmpty() && series.get(0).hasExplicitXValues();
		if ((xAxisLabels.isEmpty() && !hasCoordinatePairs) || series.isEmpty())
			return;

		// Separate bar series from other series for grouped/stacked rendering
		final java.util.List<ChartSeries> barSeries = new java.util.ArrayList<>();
		final java.util.List<HColor> barColors = new java.util.ArrayList<>();

		for (ChartSeries s : series) {
			if (s.getType() == ChartSeries.SeriesType.BAR) {
				barSeries.add(s);

				// Get bar style with stereotype support
				final Style barStyle = getBarStyle(s);

				// Extract bar color - priority: explicit color > stereotype style > element style > default color
				HColor color = s.getColor();
				if (color == null) {
					// Try to get color from style (with stereotype if present)
					color = barStyle.value(PName.BackGroundColor).asColor(skinParam.getIHtmlColorSet());
					if (color == null) {
						// Use default color
						color = getDefaultColor(series.indexOf(s));
					}
				}
				barColors.add(color);
			}
		}

		// Render bar series (grouped or stacked based on stackMode)
		if (!barSeries.isEmpty()) {
			// For now, assume all bar series use the same axis (primary Y axis)
			// Future enhancement could support mixed axes in grouped/stacked mode
			final ChartAxis axis = barSeries.get(0).isUseSecondaryAxis() && y2Axis != null ? y2Axis : yAxis;
			final boolean isHorizontal = (orientation == ChartDiagram.Orientation.HORIZONTAL);
			final BarRenderer barRenderer = new BarRenderer(skinParam, plotWidth, plotHeight, xAxisLabels.size(), axis, isHorizontal);

			if (barSeries.size() == 1) {
				// Single bar series - use simple rendering
				barRenderer.draw(ug, barSeries.get(0), barColors.get(0));
			} else {
				// Multiple bar series - use grouped or stacked rendering
				if (stackMode == ChartDiagram.StackMode.STACKED) {
					barRenderer.drawStacked(ug, barSeries, barColors);
				} else {
					barRenderer.drawGrouped(ug, barSeries, barColors);
				}
			}
		}

		// Separate area series for stacked rendering
		final java.util.List<ChartSeries> areaSeries = new java.util.ArrayList<>();
		final java.util.List<HColor> areaColors = new java.util.ArrayList<>();

		// Render non-bar, non-area series (line, scatter) first
		for (ChartSeries s : series) {
			if (s.getType() == ChartSeries.SeriesType.AREA) {
				areaSeries.add(s);

				// Get area style with stereotype support
				final Style areaStyle = getAreaStyle(s);

				// Extract series color - priority: explicit color > style color > default color
				HColor color = s.getColor();
				if (color == null) {
					// Try to get color from style
					color = areaStyle.value(PName.BackGroundColor).asColor(skinParam.getIHtmlColorSet());
					if (color == null) {
						color = areaStyle.value(PName.LineColor).asColor(skinParam.getIHtmlColorSet());
					}
					if (color == null) {
						// Use default color
						color = getDefaultColor(series.indexOf(s));
					}
				}
				areaColors.add(color);
			} else if (s.getType() != ChartSeries.SeriesType.BAR) {
				final ChartAxis axis = s.isUseSecondaryAxis() && y2Axis != null ? y2Axis : yAxis;

				// Get series-specific style based on type (with stereotype support)
				Style seriesStyle;
				switch (s.getType()) {
					case LINE:
						seriesStyle = getLineStyle(s);
						break;
					case SCATTER:
						seriesStyle = getScatterStyle(s);
						break;
					default:
						seriesStyle = getStyleSignature().getMergedStyle(skinParam.getCurrentStyleBuilder());
				}

				// Extract series color - priority: explicit color > style color > default color
				HColor color = s.getColor();
				if (color == null) {
					// Try to get color from style
					color = seriesStyle.value(PName.LineColor).asColor(skinParam.getIHtmlColorSet());
					if (color == null) {
						// Use default color
						color = getDefaultColor(series.indexOf(s));
					}
				}

				if (s.getType() == ChartSeries.SeriesType.LINE) {
					final LineRenderer lineRenderer = new LineRenderer(skinParam, plotWidth, plotHeight,
							xAxisLabels.size(), axis, xAxis);
					lineRenderer.draw(ug, s, color);
				} else if (s.getType() == ChartSeries.SeriesType.SCATTER) {
					final ScatterRenderer scatterRenderer = new ScatterRenderer(skinParam, plotWidth, plotHeight,
							xAxisLabels.size(), axis, xAxis);
					scatterRenderer.draw(ug, s, color);
				}
			}
		}

		// Render area series with stacking
		if (!areaSeries.isEmpty()) {
			// For now, assume all area series use the same axis (primary Y axis)
			final ChartAxis axis = areaSeries.get(0).isUseSecondaryAxis() && y2Axis != null ? y2Axis : yAxis;
			final AreaRenderer areaRenderer = new AreaRenderer(skinParam, plotWidth, plotHeight, xAxisLabels.size(), axis);

			// Track cumulative values for stacking
			java.util.List<Double> cumulativeValues = null;

			for (int i = 0; i < areaSeries.size(); i++) {
				final ChartSeries areaSer = areaSeries.get(i);
				final HColor color = areaColors.get(i);

				// Draw this area with the baseline from previous areas
				areaRenderer.draw(ug, areaSer, color, cumulativeValues);

				// Update cumulative values for next series
				if (cumulativeValues == null) {
					cumulativeValues = new java.util.ArrayList<>(areaSer.getValues());
				} else {
					// Add current series values to cumulative
					for (int j = 0; j < Math.min(areaSer.getValues().size(), cumulativeValues.size()); j++) {
						cumulativeValues.set(j, cumulativeValues.get(j) + areaSer.getValues().get(j));
					}
					// If current series has more values than cumulative, add them
					for (int j = cumulativeValues.size(); j < areaSer.getValues().size(); j++) {
						cumulativeValues.add(areaSer.getValues().get(j));
					}
				}
			}
		}
	}

	private double getPlotWidth() {
		return Math.max(400, xAxisLabels.size() * 60);
	}

	private double getPlotHeight() {
		return 300;
	}

	private String formatValue(double value) {
		if (Math.abs(value) < 0.01 && value != 0)
			return String.format("%.2e", value);
		if (value == (long) value)
			return String.format("%d", (long) value);
		return String.format("%.2f", value);
	}

	private HColor getDefaultColor(int index) {
		final String[] defaultColors = { "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b",
				"#e377c2", "#7f7f7f", "#bcbd22", "#17becf" };
		try {
			return skinParam.getIHtmlColorSet().getColor(defaultColors[index % defaultColors.length]);
		} catch (Exception e) {
			return null;
		}
	}

	private StyleSignatureBasic getStyleSignature() {
		return StyleSignatureBasic.of(SName.root, SName.element, SName.chartDiagram);
	}

	private StyleSignatureBasic getBarStyleSignature() {
		return StyleSignatureBasic.of(SName.root, SName.element, SName.chartDiagram, SName.bar);
	}

	private Style getBarStyle(ChartSeries series) {
		StyleSignatureBasic signature = getBarStyleSignature();
		if (series.getStereotype() != null) {
			// Use withTOBECHANGED for element-level stereotype styling
			Style style = signature.withTOBECHANGED(series.getStereotype())
				.getMergedStyle(skinParam.getCurrentStyleBuilder());

			// Use forStereotypeItself for CSS class selector styling (e.g., .primary)
			// This matches the pattern used by sequence diagrams
			Style stereoStyle = signature.forStereotypeItself(series.getStereotype())
				.getMergedStyle(skinParam.getCurrentStyleBuilder());

			// Merge with stereo style overwriting existing values
			if (style != null)
				stereoStyle = style.mergeWith(stereoStyle, MergeStrategy.OVERWRITE_EXISTING_VALUE);

			return stereoStyle;
		}
		return signature.getMergedStyle(skinParam.getCurrentStyleBuilder());
	}

	private StyleSignatureBasic getLineStyleSignature() {
		return StyleSignatureBasic.of(SName.root, SName.element, SName.chartDiagram, SName.line);
	}

	private Style getLineStyle(ChartSeries series) {
		StyleSignatureBasic signature = getLineStyleSignature();
		if (series.getStereotype() != null) {
			// Use withTOBECHANGED for element-level stereotype styling
			Style style = signature.withTOBECHANGED(series.getStereotype())
				.getMergedStyle(skinParam.getCurrentStyleBuilder());

			// Use forStereotypeItself for CSS class selector styling (e.g., line.target)
			Style stereoStyle = signature.forStereotypeItself(series.getStereotype())
				.getMergedStyle(skinParam.getCurrentStyleBuilder());

			// Merge with stereo style overwriting existing values
			if (style != null)
				stereoStyle = style.mergeWith(stereoStyle, MergeStrategy.OVERWRITE_EXISTING_VALUE);

			return stereoStyle;
		}
		return signature.getMergedStyle(skinParam.getCurrentStyleBuilder());
	}

	private StyleSignatureBasic getAreaStyleSignature() {
		return StyleSignatureBasic.of(SName.root, SName.element, SName.chartDiagram, SName.area);
	}

	private Style getAreaStyle(ChartSeries series) {
		StyleSignatureBasic signature = getAreaStyleSignature();
		if (series.getStereotype() != null) {
			// Use withTOBECHANGED for element-level stereotype styling
			Style style = signature.withTOBECHANGED(series.getStereotype())
				.getMergedStyle(skinParam.getCurrentStyleBuilder());

			// Use forStereotypeItself for CSS class selector styling (e.g., area.highlight)
			Style stereoStyle = signature.forStereotypeItself(series.getStereotype())
				.getMergedStyle(skinParam.getCurrentStyleBuilder());

			// Merge with stereo style overwriting existing values
			if (style != null)
				stereoStyle = style.mergeWith(stereoStyle, MergeStrategy.OVERWRITE_EXISTING_VALUE);

			return stereoStyle;
		}
		return signature.getMergedStyle(skinParam.getCurrentStyleBuilder());
	}

	private String formatAxisValue(double value) {
		// Format axis tick labels
		if (Math.abs(value) < 0.01 && value != 0)
			return String.format("%.2e", value);
		if (value == (long) value)
			return String.format("%d", (long) value);
		return String.format("%.1f", value);
	}

	private StyleSignatureBasic getScatterStyleSignature() {
		return StyleSignatureBasic.of(SName.root, SName.element, SName.chartDiagram, SName.scatter);
	}

	private Style getScatterStyle(ChartSeries series) {
		StyleSignatureBasic signature = getScatterStyleSignature();
		if (series.getStereotype() != null) {
			// Use withTOBECHANGED for element-level stereotype styling
			Style style = signature.withTOBECHANGED(series.getStereotype())
				.getMergedStyle(skinParam.getCurrentStyleBuilder());

			// Use forStereotypeItself for CSS class selector styling (e.g., scatter.highlight)
			Style stereoStyle = signature.forStereotypeItself(series.getStereotype())
				.getMergedStyle(skinParam.getCurrentStyleBuilder());

			// Merge with stereo style overwriting existing values
			if (style != null)
				stereoStyle = style.mergeWith(stereoStyle, MergeStrategy.OVERWRITE_EXISTING_VALUE);

			return stereoStyle;
		}
		return signature.getMergedStyle(skinParam.getCurrentStyleBuilder());
	}

	private StyleSignatureBasic getAxisStyleSignature(boolean horizontal) {
		SName axisType = horizontal ? SName.hAxis : SName.vAxis;
		return StyleSignatureBasic.of(SName.root, SName.element, SName.chartDiagram, SName.axis, axisType);
	}

	private StyleSignatureBasic getGridStyleSignature() {
		return StyleSignatureBasic.of(SName.root, SName.element, SName.chartDiagram, SName.grid);
	}

	private StyleSignatureBasic getLegendStyleSignature() {
		return StyleSignatureBasic.of(SName.root, SName.element, SName.chartDiagram, SName.legend);
	}

	private StyleSignatureBasic getAnnotationStyleSignature() {
		return StyleSignatureBasic.of(SName.root, SName.element, SName.chartDiagram, SName.annotation);
	}

	private XDimension2D calculateLegendDimension(StringBounder stringBounder) {
		if (legendPosition == ChartDiagram.LegendPosition.NONE || series.isEmpty())
			return new XDimension2D(0, 0);

		// Get legend style
		final Style legendStyle = getLegendStyleSignature()
			.getMergedStyle(skinParam.getCurrentStyleBuilder());

		// Extract legend font configuration
		final FontConfiguration fontConfig = legendStyle.getFontConfiguration(skinParam.getIHtmlColorSet());

		double maxWidth = 0;
		double totalHeight = 0;

		for (ChartSeries s : series) {
			final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), s.getName())
					.create(fontConfig, HorizontalAlignment.LEFT, skinParam);
			final XDimension2D dim = textBlock.calculateDimension(stringBounder);

			if (legendPosition == ChartDiagram.LegendPosition.LEFT || legendPosition == ChartDiagram.LegendPosition.RIGHT) {
				maxWidth = Math.max(maxWidth, dim.getWidth());
				totalHeight += dim.getHeight() + LEGEND_ITEM_SPACING;
			} else {
				maxWidth += dim.getWidth() + LEGEND_SYMBOL_SIZE + LEGEND_TEXT_SPACING + LEGEND_ITEM_SPACING;
				totalHeight = Math.max(totalHeight, dim.getHeight());
			}
		}

		if (legendPosition == ChartDiagram.LegendPosition.LEFT || legendPosition == ChartDiagram.LegendPosition.RIGHT) {
			return new XDimension2D(maxWidth + LEGEND_SYMBOL_SIZE + LEGEND_TEXT_SPACING + LEGEND_MARGIN * 2,
					totalHeight);
		} else {
			return new XDimension2D(maxWidth, totalHeight + LEGEND_MARGIN * 2);
		}
	}

	private void drawLegend(UGraphic ug, double leftMargin, double topMargin, double plotWidth, double plotHeight,
			HColor lineColor, HColor fontColor) {
		if (legendPosition == ChartDiagram.LegendPosition.NONE || series.isEmpty())
			return;

		// Get legend style
		final Style legendStyle = getLegendStyleSignature()
			.getMergedStyle(skinParam.getCurrentStyleBuilder());

		// Extract legend properties
		final FontConfiguration fontConfig = legendStyle.getFontConfiguration(skinParam.getIHtmlColorSet());

		double x = 0;
		double y = 0;

		// Calculate starting position based on legend position
		switch (legendPosition) {
		case LEFT:
			x = MARGIN;
			y = topMargin;
			break;
		case RIGHT:
			x = leftMargin + plotWidth + (y2Axis != null ? AXIS_LABEL_SPACE : 0) + LEGEND_MARGIN;
			y = topMargin;
			break;
		case TOP:
			x = leftMargin;
			y = MARGIN;
			break;
		case BOTTOM:
			x = leftMargin;
			y = topMargin + plotHeight + AXIS_LABEL_SPACE + LEGEND_MARGIN;
			break;
		default:
			return;
		}

		double currentX = x;
		double currentY = y;

		for (int i = 0; i < series.size(); i++) {
			final ChartSeries s = series.get(i);
			HColor color = s.getColor() != null ? s.getColor() : getDefaultColor(i);

			// Draw legend symbol
			if (s.getType() == ChartSeries.SeriesType.BAR) {
				// Get bar color from style if not explicitly set
				if (s.getColor() == null) {
					final Style barStyle = getBarStyle(s);
					HColor styleColor = barStyle.value(PName.BackGroundColor).asColor(skinParam.getIHtmlColorSet());
					if (styleColor != null) {
						color = styleColor;
					}
				}
				// Draw small rectangle for bar
				final net.sourceforge.plantuml.klimt.shape.URectangle rect = net.sourceforge.plantuml.klimt.shape.URectangle
						.build(LEGEND_SYMBOL_SIZE, LEGEND_SYMBOL_SIZE);
				ug.apply(color).apply(color.bg()).apply(UTranslate.dx(currentX).compose(UTranslate.dy(currentY)))
						.draw(rect);
			} else if (s.getType() == ChartSeries.SeriesType.LINE) {
				// Get line color from style if not explicitly set
				if (s.getColor() == null) {
					final Style lineStyle = getLineStyle(s);
					HColor styleColor = lineStyle.value(PName.LineColor).asColor(skinParam.getIHtmlColorSet());
					if (styleColor != null) {
						color = styleColor;
					}
				}
				// Draw small line for line chart
				final ULine line = ULine.hline(LEGEND_SYMBOL_SIZE);
				ug.apply(color).apply(UStroke.withThickness(2.0))
						.apply(UTranslate.dx(currentX).compose(UTranslate.dy(currentY + LEGEND_SYMBOL_SIZE / 2)))
						.draw(line);
			} else if (s.getType() == ChartSeries.SeriesType.AREA) {
				// Get area color from style if not explicitly set
				if (s.getColor() == null) {
					final Style areaStyle = getAreaStyle(s);
					HColor styleColor = areaStyle.value(PName.BackGroundColor).asColor(skinParam.getIHtmlColorSet());
					if (styleColor != null) {
						color = styleColor;
					}
				}
				// Draw small filled rectangle for area chart
				final net.sourceforge.plantuml.klimt.shape.URectangle rect = net.sourceforge.plantuml.klimt.shape.URectangle
						.build(LEGEND_SYMBOL_SIZE, LEGEND_SYMBOL_SIZE);
				ug.apply(color).apply(color.bg()).apply(UTranslate.dx(currentX).compose(UTranslate.dy(currentY)))
						.draw(rect);
			} else if (s.getType() == ChartSeries.SeriesType.SCATTER) {
				// Get scatter style
				final Style scatterStyle = getScatterStyle(s);

				// Get marker color from style - priority: MarkerColor > LineColor > explicit color
				HColor markerColor = color;
				HColor styleMarkerColor = scatterStyle.value(PName.MarkerColor).asColor(skinParam.getIHtmlColorSet());
				if (styleMarkerColor != null) {
					markerColor = styleMarkerColor;
				} else if (s.getColor() == null) {
					HColor styleLineColor = scatterStyle.value(PName.LineColor).asColor(skinParam.getIHtmlColorSet());
					if (styleLineColor != null) {
						markerColor = styleLineColor;
					}
				}

				// Get marker shape from style (same logic as ScatterRenderer)
				ChartSeries.MarkerShape markerShape = s.getMarkerShape();
				try {
					final String styleMarkerShape = scatterStyle.value(PName.MarkerShape).asString();
					if (styleMarkerShape != null && !styleMarkerShape.isEmpty()) {
						switch (styleMarkerShape.toLowerCase()) {
							case "circle":
								markerShape = ChartSeries.MarkerShape.CIRCLE;
								break;
							case "square":
								markerShape = ChartSeries.MarkerShape.SQUARE;
								break;
							case "triangle":
								markerShape = ChartSeries.MarkerShape.TRIANGLE;
								break;
						}
					}
				} catch (Exception e) {
					// Use default
				}
				// Draw marker shape for scatter plot
				drawLegendScatterMarker(ug, markerColor, currentX + LEGEND_SYMBOL_SIZE / 2, currentY + LEGEND_SYMBOL_SIZE / 2, LEGEND_SYMBOL_SIZE * 0.7, markerShape);
			}

			// Draw series name
			final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), s.getName())
					.create(fontConfig, HorizontalAlignment.LEFT, skinParam);
			final XDimension2D textDim = textBlock.calculateDimension(ug.getStringBounder());
			textBlock.drawU(ug.apply(UTranslate.dx(currentX + LEGEND_SYMBOL_SIZE + LEGEND_TEXT_SPACING)
					.compose(UTranslate.dy(currentY))));

			// Move to next item position
			if (legendPosition == ChartDiagram.LegendPosition.LEFT || legendPosition == ChartDiagram.LegendPosition.RIGHT) {
				currentY += textDim.getHeight() + LEGEND_ITEM_SPACING;
			} else {
				currentX += LEGEND_SYMBOL_SIZE + LEGEND_TEXT_SPACING + textDim.getWidth() + LEGEND_ITEM_SPACING;
			}
		}
	}

	private void drawLegendScatterMarker(UGraphic ug, HColor color, double x, double y, double size, ChartSeries.MarkerShape shape) {
		switch (shape) {
		case CIRCLE:
			final net.sourceforge.plantuml.klimt.shape.UEllipse circle = net.sourceforge.plantuml.klimt.shape.UEllipse.build(size, size);
			ug.apply(color).apply(color.bg()).apply(UTranslate.dx(x - size / 2).compose(UTranslate.dy(y - size / 2))).draw(circle);
			break;
		case SQUARE:
			final net.sourceforge.plantuml.klimt.shape.URectangle square = net.sourceforge.plantuml.klimt.shape.URectangle.build(size, size);
			ug.apply(color).apply(color.bg()).apply(UTranslate.dx(x - size / 2).compose(UTranslate.dy(y - size / 2))).draw(square);
			break;
		case TRIANGLE:
			final net.sourceforge.plantuml.klimt.shape.UPolygon triangle = new net.sourceforge.plantuml.klimt.shape.UPolygon();
			triangle.addPoint(0, -size / 2);
			triangle.addPoint(-size / 2, size / 2);
			triangle.addPoint(size / 2, size / 2);
			ug.apply(color).apply(color.bg()).apply(UTranslate.dx(x).compose(UTranslate.dy(y))).draw(triangle);
			break;
		}
	}

	private void drawAnnotations(UGraphic ug, double plotWidth, double plotHeight, HColor lineColor, HColor fontColor) {
		if (annotations == null || annotations.isEmpty())
			return;

		// Get annotation style
		final Style annotationStyle = getAnnotationStyleSignature()
			.getMergedStyle(skinParam.getCurrentStyleBuilder());

		// Extract annotation properties
		final FontConfiguration fontConfig = annotationStyle.getFontConfiguration(skinParam.getIHtmlColorSet());

		// Extract arrow line color
		HColor arrowColor = annotationStyle.value(PName.LineColor)
			.asColor(skinParam.getIHtmlColorSet());
		if (arrowColor == null) {
			// Fallback to black
			arrowColor = HColors.BLACK;
			try {
				arrowColor = skinParam.getIHtmlColorSet().getColor("#000000");
			} catch (Exception e) {
				arrowColor = lineColor;
			}
		}

		// Extract arrow line thickness
		final double arrowThickness = annotationStyle.value(PName.LineThickness).asDouble();

		for (ChartAnnotation annotation : annotations) {
			// Calculate the pixel position of the annotation
			double x = 0;
			double y = 0;

			// Handle X position (categorical or numeric)
			if (annotation.getXPosition() instanceof Double) {
				// Numeric X position - convert to pixel coordinate
				double xValue = (Double) annotation.getXPosition();
				if (orientation == ChartDiagram.Orientation.HORIZONTAL) {
					// For horizontal, numeric axis is Y
					y = plotHeight * (1.0 - (xValue - yAxis.getMin()) / (yAxis.getMax() - yAxis.getMin()));
				} else {
					// For vertical, numeric X would map across the plot width
					// This is unusual but support it for flexibility
					x = plotWidth * ((xValue - yAxis.getMin()) / (yAxis.getMax() - yAxis.getMin()));
				}
			} else if (annotation.getXPosition() instanceof String) {
				// Categorical X position - find index in labels
				String xLabel = (String) annotation.getXPosition();
				int index = xAxisLabels.indexOf(xLabel);
				if (index < 0)
					continue; // Label not found, skip this annotation

				if (orientation == ChartDiagram.Orientation.HORIZONTAL) {
					// For horizontal, categories are on Y axis
					y = plotHeight * (index + 0.5) / xAxisLabels.size();
				} else {
					// For vertical, categories are on X axis
					x = plotWidth * (index + 0.5) / xAxisLabels.size();
				}
			}

			// Handle Y position (always numeric)
			if (orientation == ChartDiagram.Orientation.HORIZONTAL) {
				// For horizontal, numeric axis is X (yAxis holds the numeric range)
				x = plotWidth * ((annotation.getYPosition() - yAxis.getMin()) / (yAxis.getMax() - yAxis.getMin()));
			} else {
				// For vertical, numeric axis is Y
				y = plotHeight * (1.0 - (annotation.getYPosition() - yAxis.getMin()) / (yAxis.getMax() - yAxis.getMin()));
			}

			// Create text block for annotation
			final TextBlock textBlock = Display.getWithNewlines(skinParam.getPragma(), annotation.getText())
					.create(fontConfig, HorizontalAlignment.LEFT, skinParam);
			final XDimension2D textDim = textBlock.calculateDimension(ug.getStringBounder());

			// Draw arrow if requested
			if (annotation.isShowArrow()) {
				// Check if there's enough space above the point for text + arrow
				final double minSpaceAbove = textDim.getHeight() + 40;
				final boolean placeAbove = y > minSpaceAbove;

				if (placeAbove) {
					// Position text above the data point with arrow pointing down
					final double textX = x - textDim.getWidth() / 2;
					final double textY = y - textDim.getHeight() - 40;

					// Draw text first
					textBlock.drawU(ug.apply(UTranslate.dx(textX).compose(UTranslate.dy(textY))));

					// Draw arrow from bottom of text to data point
					final double arrowStartY = textY + textDim.getHeight() + 5;
					final double arrowEndY = y - 5;
					final double arrowLength = arrowEndY - arrowStartY;

					if (arrowLength > 0) {
						final ULine arrowLine = new ULine(0, arrowLength);
						ug.apply(arrowColor).apply(UStroke.withThickness(arrowThickness))
								.apply(UTranslate.dx(x).compose(UTranslate.dy(arrowStartY)))
								.draw(arrowLine);

						// Draw arrowhead pointing down
						final net.sourceforge.plantuml.klimt.shape.UPolygon arrowhead = new net.sourceforge.plantuml.klimt.shape.UPolygon();
						arrowhead.addPoint(0, 0);
						arrowhead.addPoint(-5, -8);
						arrowhead.addPoint(5, -8);
						ug.apply(arrowColor).apply(arrowColor.bg())
								.apply(UTranslate.dx(x).compose(UTranslate.dy(arrowEndY)))
								.draw(arrowhead);
					}
				} else {
					// Position text below the data point with arrow pointing up
					final double textX = x - textDim.getWidth() / 2;
					final double textY = y + 40;

					// Draw text below
					textBlock.drawU(ug.apply(UTranslate.dx(textX).compose(UTranslate.dy(textY))));

					// Draw arrow from top of text to data point
					final double arrowStartY = textY - 5;
					final double arrowEndY = y + 5;
					final double arrowLength = arrowStartY - arrowEndY;

					if (arrowLength > 0) {
						final ULine arrowLine = new ULine(0, -arrowLength);
						ug.apply(arrowColor).apply(UStroke.withThickness(arrowThickness))
								.apply(UTranslate.dx(x).compose(UTranslate.dy(arrowStartY)))
								.draw(arrowLine);

						// Draw arrowhead pointing up
						final net.sourceforge.plantuml.klimt.shape.UPolygon arrowhead = new net.sourceforge.plantuml.klimt.shape.UPolygon();
						arrowhead.addPoint(0, 0);
						arrowhead.addPoint(-5, 8);
						arrowhead.addPoint(5, 8);
						ug.apply(arrowColor).apply(arrowColor.bg())
								.apply(UTranslate.dx(x).compose(UTranslate.dy(arrowEndY)))
								.draw(arrowhead);
					}
				}
			} else {
				// Draw text near the point without arrow
				textBlock.drawU(ug.apply(UTranslate.dx(x - textDim.getWidth() / 2)
						.compose(UTranslate.dy(y - textDim.getHeight() - 5))));
			}
		}
	}

}