commit da25721f52c0dfd903e3554a291e24a2f8e9bb32 Author: Andrea Cavalli Date: Tue Dec 7 00:46:15 2021 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d0faf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,182 @@ +# ---> Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +# https://github.com/takari/maven-wrapper#usage-without-binary-jar +.mvn/wrapper/maven-wrapper.jar + +# ---> Java +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# ---> Linux +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# ---> Windows +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# ---> macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# ---> JetBrains +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +/.idea/ diff --git a/.preview.png b/.preview.png new file mode 100644 index 0000000..1ed97af Binary files /dev/null and b/.preview.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b650cb1 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# JLineGraph + +Small library to draw line graphs in java + +![.preview.png](.preview.png) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..03b8c3f --- /dev/null +++ b/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + it.cavallium + jlinegraph + 1.0-SNAPSHOT + + + 17 + 17 + + + + mchv + MCHV Apache Maven Packages + https://mvn.mchv.eu/repository/mchv/ + + + + + mchv-release-distribution + MCHV Release Apache Maven Packages Distribution + https://mvn.mchv.eu/repository/mchv + + + mchv-snapshot-distribution + MCHV Snapshot Apache Maven Packages Distribution + https://mvn.mchv.eu/repository/mchv-snapshot + + + + \ No newline at end of file diff --git a/src/main/java/it/cavallium/jlinegraph/AWTBufferedGraphRenderer.java b/src/main/java/it/cavallium/jlinegraph/AWTBufferedGraphRenderer.java new file mode 100644 index 0000000..572bd5e --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/AWTBufferedGraphRenderer.java @@ -0,0 +1,19 @@ +package it.cavallium.jlinegraph; + +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; + +public class AWTBufferedGraphRenderer implements IGraphRenderer { + + @Override + public BufferedImage renderGraph(Graph graph, RasterSize totalSize) { + BufferedImage image = new BufferedImage((int) totalSize.width(), + (int) totalSize.height(), + BufferedImage.TYPE_INT_ARGB + ); + Graphics2D graphics2D = image.createGraphics(); + AWTGraphRenderer.renderGraph(graphics2D, graph, totalSize); + return image; + } + +} diff --git a/src/main/java/it/cavallium/jlinegraph/AWTGraphExample.java b/src/main/java/it/cavallium/jlinegraph/AWTGraphExample.java new file mode 100644 index 0000000..834a426 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/AWTGraphExample.java @@ -0,0 +1,108 @@ +package it.cavallium.jlinegraph; + +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.util.List; +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.WindowConstants; + +public class AWTGraphExample { + + private static final GraphColors GRAPH_COLOR = GraphColors.DARK; + + public static void main(String[] args) { + var jf = new JFrame("Graph"); + jf.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); + jf.setLocationByPlatform(true); + var jp = new JComponent() { + @Override + protected void paintComponent(Graphics g) { + generateGraph(this.getWidth(), this.getHeight(), (Graphics2D) g); + } + }; + jf.setBackground(GRAPH_COLOR.background().toColor()); + jf.add(jp); + jf.setPreferredSize(new Dimension(800, 600)); + jf.pack(); + jf.setVisible(true); + } + + private static void generateGraph(int w, int h, Graphics2D g2d) { + var g = new Graph("Example", new GraphData(List.of( + new SeriesData(List.of( + new Vertex(-3, -3), + new Vertex(0, 0), + new Vertex(3, 1), + new Vertex(3.5, 7), + new Vertex(7, 0.5), + new Vertex(15, 14) + ), true, "Data1"), + new SeriesData(List.of( + new Vertex(-3, -2), + new Vertex(0, 3), + new Vertex(1, 1), + new Vertex(2, 5), + new Vertex(3, 6), + new Vertex(4, 7), + new Vertex(5, 4), + new Vertex(6, 3), + new Vertex(7, 1), + new Vertex(8, 10), + new Vertex(9, 12), + new Vertex(10, 13), + new Vertex(11, 15), + new Vertex(12, 11), + new Vertex(13, 15), + new Vertex(14, 10), + new Vertex(15, 4) + ), true, "Data2"), + new SeriesData(List.of( + new Vertex(-3, -1), + new Vertex(0, 3), + new Vertex(4, 4), + new Vertex(8, 3), + new Vertex(12, 4), + new Vertex(15, 3) + ), true, "Data3"), + new SeriesData(List.of( + new Vertex(4.4, 5.2), + new Vertex(6.4, 7.2), + new Vertex(8.4, 5.2), + new Vertex(6.4, 3.2), + new Vertex(4.4, 5.2) + ), false, "full oval"), + new SeriesData(List.of( + new Vertex(-6+4.4, 5.8), + new Vertex(-6+6.4, 7.8), + new Vertex(-6+8.4, 5.8), + new Vertex(-6+6.4, 3.8), + new Vertex(-6+4.4, 5.8) + ), false, "oval line"), + new SeriesData(List.of( + new Vertex(3.3+4, 2.3+8), + new Vertex(3.3+5, 2.3+7), + new Vertex(3.3+8, 2.3+5), + new Vertex(3.3+6, 2.3+3) + ), false, "open path") + )), + new GraphStyle(List.of( + new SeriesStyle(new Color(0f, 1f, 0f, 1f), 1, 1, 0, 1d), + new SeriesStyle(new Color(1f, 0f, 0f, 1f), 1, 0, 0, 1d), + new SeriesStyle(new Color(0.5f, 1f, 1f, 1f), 0, 1, 0.3, 1d), + new SeriesStyle(new Color(0.5f, 1f, 0.5f, 1f), 0, 0, 1, 1d), + new SeriesStyle(new Color(0.5f, 1f, 0.5f, 1f), 0, 1, 0.3, 1d), + new SeriesStyle(new Color(1f, 1f, 0.7f, 1f), 1.5, 2, 0, 1d) + ), + new GraphAxisStyle("X axis", true, "%.2fs"::formatted), + new GraphAxisStyle("Y axis", true, "%.2fm"::formatted), + GRAPH_COLOR, + new GraphFonts(10f, 18f, 12f, 12f), + 2f, + true + )); + var r = new AWTGraphRenderer(); + r.renderGraph(g, new RasterSize(w, h)).drawTo(g2d); + } +} diff --git a/src/main/java/it/cavallium/jlinegraph/AWTGraphRenderer.java b/src/main/java/it/cavallium/jlinegraph/AWTGraphRenderer.java new file mode 100644 index 0000000..6d7e290 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/AWTGraphRenderer.java @@ -0,0 +1,793 @@ +package it.cavallium.jlinegraph; + +import it.cavallium.jlinegraph.AWTGraphRenderer.AWTDrawer; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.Shape; +import java.awt.font.GlyphVector; +import java.awt.geom.Ellipse2D; +import java.awt.geom.GeneralPath; +import java.awt.geom.Line2D; +import java.awt.geom.Path2D; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public class AWTGraphRenderer implements IGraphRenderer { + + private static final int MAX_LABELS = 1000; + + @Override + public AWTDrawer renderGraph(Graph graph, RasterSize totalSize) { + return graphics2D -> renderGraph(graphics2D, graph, totalSize); + } + + public static void renderGraph(Graphics2D graphics2D, Graph graph, RasterSize totalSize) { + graphics2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics2D.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + graphics2D.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + graphics2D.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_GASP); + graphics2D.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); + graphics2D.setRenderingHint(RenderingHints.KEY_DITHERING, RenderingHints.VALUE_DITHER_ENABLE); + graphics2D.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + graphics2D.setRenderingHint(RenderingHints.KEY_RESOLUTION_VARIANT, RenderingHints.VALUE_RESOLUTION_VARIANT_DPI_FIT); + + Font defaultFont = graphics2D.getFont().deriveFont((float) graph.style().fonts().global()); + Font valuesFont = defaultFont.deriveFont((float) graph.style().fonts().valueLabel()); + Font axisNameFont = defaultFont.deriveFont((float) graph.style().fonts().axisName()); + + var defaultFontMetrics = graphics2D.getFontMetrics(defaultFont); + var valuesFontMetrics = graphics2D.getFontMetrics(valuesFont); + var axisNameFontMetrics = graphics2D.getFontMetrics(axisNameFont); + + var graphBounds = graph.data().bounds(); + var x = graph.style().x(); + var y = graph.style().y(); + var scaleX = new NiceScale(graphBounds.minX(), graphBounds.maxX()); + scaleX.setMaxTicks(20); + var scaleY = new NiceScale(graphBounds.minY(), graphBounds.maxY()); + scaleY.setMaxTicks(20); + var topPadding = defaultFontMetrics.getHeight() + valuesFontMetrics.getHeight() / 2d; + var leftPadding = defaultFontMetrics.getHeight(); + var rightPadding = defaultFontMetrics.getHeight() + + valuesFontMetrics.stringWidth(y.valueFormat().apply(graphBounds.maxX())) / 2d; + var bottomPadding = defaultFontMetrics.getHeight(); + var xValueLineLength = valuesFontMetrics.getHeight(); + var yValueLineLength = valuesFontMetrics.getHeight(); + var xValuesHeight = valuesFontMetrics.getHeight(); + var xValuesToXAxisNamePadding = (x.show() ? valuesFontMetrics.getHeight() : 0); + var xAxisNameHeight = (x.show() ? axisNameFontMetrics.getHeight() : 0); + var yAxisNameWidth = (y.show() ? axisNameFontMetrics.getHeight() : 0); + var yValuesToYAxisNamePadding = (y.show() ? valuesFontMetrics.getHeight() : 0); + + var graphHeight + // Start with total height + = totalSize.height() + // Remove the padding on top + - topPadding + // Remove the x value lines length + - xValueLineLength + // Remove the values height + - xValuesHeight + // Remove the space between the values and the axis name + - xValuesToXAxisNamePadding + // Remove x-axis name height + - xAxisNameHeight + // Remove the padding on bottom + - bottomPadding; + + var xValueLineOffset = topPadding + graphHeight; + + var yLabels = getYLabels(graph, graphHeight, valuesFontMetrics, scaleY); + RasterSize yLabelsAreaSize = computeYLabelsAreaSize(graphHeight, valuesFontMetrics, yLabels); + var yValuesWidth = yLabelsAreaSize.width(); + var yValueLineOffset = leftPadding + yAxisNameWidth + yValuesToYAxisNamePadding + yValuesWidth; + + var graphWidth + // Start with total width + = totalSize.width() + // Remove the padding on left + - leftPadding + // Remove y-axis name "90deg height" + - yAxisNameWidth + // Remove the space between the values and the axis name + - yValuesToYAxisNamePadding + // Remove the y values width + - yValuesWidth + // Remove the y value lines length + - yValueLineLength + // Remove the padding on right + - rightPadding; + + Font seriesNameFont = null; + FontMetrics seriesNameFontMetrics = null; + + if (graph.style().showLegend()) { + double legendSizeW; + double legendSizeH; + + seriesNameFont = defaultFont.deriveFont((float) graph.style().fonts().seriesName()); + seriesNameFontMetrics = graphics2D.getFontMetrics(seriesNameFont); + legendSizeW = getLegendSizeW(graph, seriesNameFontMetrics); + legendSizeH = getLegendSizeH(graph, seriesNameFontMetrics); + + if (legendSizeW > graphWidth / 3d || legendSizeH > graphHeight / 2.5d) { + var newFontSizeW = (float) (seriesNameFont.getSize() * ((graphWidth / 3d) / legendSizeW)); + var newFontSizeH = (float) (seriesNameFont.getSize() * ((graphHeight / 2.5d) / legendSizeH)); + seriesNameFont = seriesNameFont.deriveFont(Math.min(newFontSizeW, newFontSizeH)); + seriesNameFontMetrics = graphics2D.getFontMetrics(seriesNameFont); + } + } + + var xLabels = getXLabels(graph, graphWidth, valuesFontMetrics, scaleX); + + RasterSize yAxisNameCenterOffset = new RasterSize(valuesFontMetrics.getHeight(), valuesFontMetrics.getHeight() + // Add half of graph height + + graphHeight / 2); + + RasterSize yValuesOffset = new RasterSize(leftPadding + // Add y axis name "90deg height" + + yAxisNameWidth + // Add the space between the values and the axis name + + yValuesToYAxisNamePadding, topPadding); + + RasterSize graphOffset = new RasterSize(leftPadding + + yAxisNameWidth + + yValuesToYAxisNamePadding + + yValuesWidth + + yValueLineLength, topPadding); + RasterSize xValuesOffset = new RasterSize(graphOffset.width(), xValueLineOffset + xValueLineLength); + + RasterSize xAxisNameCenterOffset = new RasterSize(leftPadding + + yAxisNameWidth + + yValuesToYAxisNamePadding + + yValuesWidth + + yValueLineLength + // Add half of graph width + + graphWidth / 2, topPadding + // Add graph height + + graphHeight + // Add the x value lines length + + xValueLineLength + // Add the x values height + + xValuesHeight + // Add the space between the values and the axis name + + xValuesToXAxisNamePadding + // Add x-axis half name height + + axisNameFontMetrics.getHeight() / 2d); + + RasterSize graphSize = new RasterSize(graphWidth, graphHeight); + + var bgColor = graph.style().colors().background().toColor(); + var fgColor = graph.style().colors().foreground().toColor(); + var strokeWidth = graph.style().strokeWidth(); + var defaultStroke = new BasicStroke((float) strokeWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND, BasicStroke.JOIN_MITER); + + try { + graphics2D.setBackground(bgColor); + graphics2D.clearRect(0, 0, (int) totalSize.width(), (int) totalSize.height()); + + if (graphHeight < 0) { + return; + } + if (graphWidth < 0) { + return; + } + + renderGraphBorders(graphics2D, graph, graphOffset, graphSize, defaultStroke); + if (y.show()) { + renderYAxisName(graphics2D, graph, yAxisNameCenterOffset, axisNameFont, axisNameFontMetrics); + } + if (x.show()) { + renderXAxisName(graphics2D, graph, xAxisNameCenterOffset, axisNameFont, axisNameFontMetrics); + } + renderYAxisValueLabels(graphics2D, + graph, + valuesFont, + valuesFontMetrics, + yValueLineOffset, + yValueLineLength, + yLabels, + yLabelsAreaSize, + yValuesOffset, + defaultStroke + ); + renderXAxisValueLabels(graphics2D, + graph, + valuesFont, + valuesFontMetrics, + xValueLineOffset, + xValueLineLength, + xLabels, xValuesOffset, + defaultStroke + ); + + + var seriesGraphics2D = (Graphics2D) graphics2D.create(); + seriesGraphics2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + try { + seriesGraphics2D.setClip(new Rectangle2D.Double(graphOffset.width() - defaultStroke.getLineWidth(), + graphOffset.height() - defaultStroke.getLineWidth(), + graphSize.width() + defaultStroke.getLineWidth() * 2d, + graphSize.height() + defaultStroke.getLineWidth() * 2d + )); + + var zeroLineStroke = new BasicStroke((float) strokeWidth, + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND, + 10.0f, + new float[]{2.0f, 3.0f}, + 0.0f + ); + var zeroLineColor = graph.style().colors().foreground().multiplyOpacity(0.5f).toColor(); + + if ((graphBounds.minY() < 0 && graphBounds.maxY() > 0) + || (graphBounds.minY() > 0 && graphBounds.maxY() < 0)) { + double rasterZeroY = graphOffset.height() + graphSize.height() + - ((-graphBounds.minY()) / (graphBounds.maxY() - graphBounds.minY())) * graphSize.height(); + seriesGraphics2D.setColor(zeroLineColor); + seriesGraphics2D.setStroke(zeroLineStroke); + seriesGraphics2D.draw(new Line2D.Double(graphOffset.width(), + rasterZeroY, + graphOffset.width() + graphWidth, + rasterZeroY + )); + } + if ((graphBounds.minX() < 0 && graphBounds.maxX() > 0) + || (graphBounds.minX() > 0 && graphBounds.maxX() < 0)) { + double rasterZeroX = graphOffset.width() + + ((-graphBounds.minX()) / (graphBounds.maxX() - graphBounds.minX())) * graphSize.width(); + seriesGraphics2D.setColor(zeroLineColor); + seriesGraphics2D.setStroke(zeroLineStroke); + seriesGraphics2D.draw(new Line2D.Double( + rasterZeroX, + graphOffset.height(), + rasterZeroX, + graphOffset.height() + graphHeight + )); + } + + int i = 0; + for (SeriesData series : graph.data().series()) { + var seriesStyleSize = graph.style().seriesStyles().size(); + if (graph.style().seriesStyles().isEmpty()) { + throw new IllegalArgumentException("No styles found"); + } + SeriesStyle style = graph.style().seriesStyles().get(i % seriesStyleSize); + BasicStroke seriesStroke = getSeriesStroke(style, strokeWidth); + BasicStroke seriesPointsStroke = getSeriesPointsStroke(style, strokeWidth); + drawSeries(seriesGraphics2D, graphBounds, + graphOffset, + graphSize, + series, + style, + seriesStroke, + seriesPointsStroke + ); + i++; + } + } finally { + seriesGraphics2D.dispose(); + } + + if (graph.style().showLegend()) { + drawSeriesLegend(graphics2D, + graph, + graphOffset, + graphSize, + seriesNameFont, + seriesNameFontMetrics, + defaultStroke, + fgColor, + strokeWidth + ); + } + } finally { + graphics2D.dispose(); + } + } + + private static void drawSeriesLegend(Graphics2D graphics2D, + Graph graph, + RasterSize graphOffset, + RasterSize graphSize, + Font seriesNameFont, + FontMetrics seriesNameFontMetrics, + BasicStroke defaultStroke, + Color fgColor, + double strokeWidth) { + double seriesPadding = getSeriesPadding(seriesNameFontMetrics); + double seriesMargin = getSeriesMargin(seriesNameFontMetrics); + double seriesPreviewLineWidth = seriesNameFontMetrics.getHeight() * 2; + double singleSeriesHeight = seriesNameFontMetrics.getHeight(); + + double legendSizeW = getLegendSizeW(graph, seriesNameFontMetrics); + double legendSizeH = getLegendSizeH(graph, seriesNameFontMetrics); + + double legendOffsetX = graphOffset.width() + + graphSize.width() + - seriesMargin + - legendSizeW; + double legendOffsetY = graphOffset.height() + + seriesMargin; + + var legendRect = new Rectangle2D.Double(legendOffsetX, legendOffsetY, legendSizeW, legendSizeH); + + graphics2D.setStroke(defaultStroke); + + graphics2D.setColor(graph.style().colors().background().multiplyOpacity(0.75f).toColor()); + graphics2D.fill(legendRect); + graphics2D.setColor(fgColor); + graphics2D.draw(legendRect); + + int i = 0; + for (SeriesData series : graph.data().series()) { + var seriesStyleSize = graph.style().seriesStyles().size(); + if (graph.style().seriesStyles().isEmpty()) { + throw new IllegalArgumentException("No styles found"); + } + SeriesStyle style = graph.style().seriesStyles().get(i % seriesStyleSize); + + var seriesName = series.name(); + var stroke = new BasicStroke((float) (strokeWidth * 2f), BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER); + graphics2D.setColor(style.color().overrideOpacity(1.0f).toColor()); + graphics2D.setStroke(stroke); + var lineOffsetX = legendOffsetX + seriesPadding; + var currentOffsetY = legendOffsetY + seriesPadding / 2d + + i * (seriesPadding / 2d + singleSeriesHeight + seriesPadding / 2d) + + seriesPadding / 2d; + var lineOffsetY = currentOffsetY + singleSeriesHeight / 2d; + graphics2D.draw(new Line2D.Double(lineOffsetX, lineOffsetY, lineOffsetX + seriesPreviewLineWidth, lineOffsetY)); + var textOffsetX = lineOffsetX + seriesPreviewLineWidth + seriesPadding; + var textOffsetY = currentOffsetY + seriesNameFontMetrics.getAscent(); + graphics2D.setColor(fgColor); + graphics2D.setFont(seriesNameFont); + graphics2D.fill(generateShapeFromText(graphics2D, seriesName, textOffsetX, textOffsetY)); + i++; + } + } + + private static double getSeriesMargin(FontMetrics seriesNameFontMetrics) { + return seriesNameFontMetrics.getHeight() * 2d / 3d; + } + + private static double getLegendSizeW(Graph graph, FontMetrics seriesNameFontMetrics) { + double seriesPadding = getSeriesPadding(seriesNameFontMetrics); + double seriesTextMaxWidth = getSeriesTextMaxWidth(graph, seriesNameFontMetrics); + double seriesPreviewLineWidth = seriesNameFontMetrics.getHeight() * 2; + return seriesPadding + + seriesTextMaxWidth + + seriesPadding + + seriesPreviewLineWidth + + seriesPadding; + } + + private static double getSeriesPadding(FontMetrics seriesNameFontMetrics) { + return seriesNameFontMetrics.getHeight() / 3d; + } + + private static double getLegendSizeH(Graph graph, FontMetrics seriesNameFontMetrics) { + int seriesCount = graph.data().series().size(); + double seriesPadding = getSeriesPadding(seriesNameFontMetrics); + double singleSeriesHeight = seriesNameFontMetrics.getHeight(); + return seriesPadding / 2d + + seriesCount * (seriesPadding / 2d + singleSeriesHeight + seriesPadding / 2d) + + seriesPadding / 2d; + } + + private static double getSeriesTextMaxWidth(Graph graph, FontMetrics seriesNameFontMetrics) { + double seriesTextMaxWidth = 0; + for (SeriesData series : graph.data().series()) { + var seriesName = series.name(); + var seriesNameRasterWidth = seriesNameFontMetrics.stringWidth(seriesName); + if (seriesTextMaxWidth < seriesNameRasterWidth) { + seriesTextMaxWidth = seriesNameRasterWidth; + } + } + return seriesTextMaxWidth; + } + + private static void drawSeries(Graphics2D seriesGraphics2D, + GraphBounds graphBounds, + RasterSize graphOffset, + RasterSize graphSize, + SeriesData series, + SeriesStyle style, + BasicStroke seriesStroke, + BasicStroke seriesPointsStroke) { + var lineColor = style.color().toColor(); + var areaColor = style.color().multiplyOpacity((float) style.areaOpacity()).toColor(); + var points = new java.awt.geom.Point2D.Double[series.vertices().size()]; + + double rasterMinY = graphOffset.height() + graphSize.height() + - ((0 - graphBounds.minY()) / (graphBounds.maxY() - graphBounds.minY())) * graphSize.height(); + int i = 0; + for (Vertex vertex : series.vertices()) { + double rasterX = graphOffset.width() + + ((vertex.x() - graphBounds.minX()) / (graphBounds.maxX() - graphBounds.minX())) * graphSize.width(); + double rasterY = graphOffset.height() + graphSize.height() + - ((vertex.y() - graphBounds.minY()) / (graphBounds.maxY() - graphBounds.minY())) * graphSize.height(); + points[i] = new java.awt.geom.Point2D.Double(rasterX, rasterY); + i++; + } + // Sort points if it's a function + if (series.isFunction()) { + Arrays.sort(points, Comparator.comparingDouble(Point2D.Double::getX)); + } + + seriesGraphics2D.setStroke(seriesPointsStroke); + if (style.pointsWeight() != 0) { + seriesGraphics2D.setColor(lineColor); + for (var point : points) { + seriesGraphics2D.fill(new Ellipse2D.Double(point.getX() - seriesPointsStroke.getLineWidth(), + point.getY() - seriesPointsStroke.getLineWidth(), + seriesPointsStroke.getLineWidth() * 2f, + seriesPointsStroke.getLineWidth() * 2f + ) {}); + } + } + if (style.lineWeight() != 0 || style.areaOpacity() > 0d) { + if (style.smoothness() > 0d && points.length >= 3) { + var mPath = new GeneralPath(Path2D.WIND_NON_ZERO, points.length); + var areaPath = new GeneralPath(Path2D.WIND_NON_ZERO, points.length); + + if (series.isFunction()) { + areaPath.moveTo(points[0].x, rasterMinY); + mPath.moveTo(points[0].x, points[0].y); + areaPath.lineTo(points[0].x, points[0].y); + } else { + areaPath.moveTo(points[0].x, points[0].y); + mPath.moveTo(points[0].x, points[0].y); + } + + double SMOOTHNESS = style.smoothness() / 2d; // higher is smoother, but don't go over 0.5 + + if (!series.isFunction()) { + Point2D.Double[] bezierPoints; + final boolean closedPath = Objects.equals(points[points.length - 1], points[0]); + if (closedPath) { + bezierPoints = new Point2D.Double[points.length + 2]; + bezierPoints[0] = points[points.length - 2]; + System.arraycopy(points, 0, bezierPoints, 1, points.length); + bezierPoints[bezierPoints.length - 1] = points[1]; + } else { + bezierPoints = points; + } + var bez = new Bezier(bezierPoints); + Point2D[] b = bez.getPoints(); + + if (!closedPath) { + mPath.quadTo(b[0].getX(), b[0].getY(), bezierPoints[1].x, bezierPoints[1].getY()); + areaPath.quadTo(b[0].getX(), b[0].getY(), bezierPoints[1].x, bezierPoints[1].getY()); + } + + for (int w = 2; w < bezierPoints.length - 1; w++) { + Point2D b0 = b[2 * w - 3]; + Point2D b1 = b[2 * w - 2]; + double cp1X = b0.getX(); + double cp1Y = b0.getY(); + double cp2X = b1.getX(); + double cp2Y = b1.getY(); + double endPointX = bezierPoints[w].getX(); + double endPointY = bezierPoints[w].getY(); + mPath.curveTo(cp1X, cp1Y, cp2X, cp2Y, endPointX, endPointY); + areaPath.curveTo(cp1X, cp1Y, cp2X, cp2Y, endPointX, endPointY); + } + + if (!closedPath) { + mPath.quadTo(b[b.length - 1].getX(), + b[b.length - 1].getY(), + bezierPoints[bezierPoints.length - 1].x, + bezierPoints[bezierPoints.length - 1].getY() + ); + areaPath.quadTo(b[b.length - 1].getX(), + b[b.length - 1].getY(), + bezierPoints[bezierPoints.length - 1].x, + bezierPoints[bezierPoints.length - 1].getY() + ); + } + } else { + // calculate smooth path + double lX = 0, lY = 0; + int size = points.length; + for (int pointIndex=1; pointIndex 0d) { + seriesGraphics2D.setColor(areaColor); + seriesGraphics2D.fill(areaPath); + } + if (style.lineWeight() != 0) { + seriesGraphics2D.setStroke(seriesStroke); + seriesGraphics2D.setColor(lineColor); + seriesGraphics2D.draw(mPath); + } + } else { + var areaPath = new Path2D.Double(); + var path = new Path2D.Double(); + if (points.length > 0) { + areaPath.moveTo(points[0].x, rasterMinY); + } + boolean first = true; + for (Point2D.Double point : points) { + if (first) { + path.moveTo(point.getX(), point.getY()); + first = false; + } else { + path.lineTo(point.getX(), point.getY()); + } + areaPath.lineTo(point.getX(), point.getY()); + } + if (points.length > 0) { + areaPath.moveTo(points[points.length - 1].x, rasterMinY); + areaPath.closePath(); + } + if (style.areaOpacity() > 0d) { + seriesGraphics2D.setStroke(seriesStroke); + seriesGraphics2D.setColor(areaColor); + seriesGraphics2D.fill(areaPath); + } + if (style.lineWeight() != 0) { + seriesGraphics2D.setStroke(seriesStroke); + seriesGraphics2D.setColor(lineColor); + seriesGraphics2D.draw(path); + } + } + } + } + + private static BasicStroke getSeriesStroke(SeriesStyle seriesStyle, double defaultStrokeWidth) { + return new BasicStroke((float) (defaultStrokeWidth * seriesStyle.lineWeight()), + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND + ); + } + + private static BasicStroke getSeriesPointsStroke(SeriesStyle seriesStyle, double defaultStrokeWidth) { + return new BasicStroke((float) (defaultStrokeWidth * 2d * seriesStyle.pointsWeight()), + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND + ); + } + + private static void renderYAxisName(Graphics2D graphics2D, + Graph graph, + RasterSize yAxisNameCenterOffset, + Font axisNameFont, + FontMetrics axisNameFontMetrics) { + var fgColor = graph.style().colors().foreground().toColor(); + graphics2D.setColor(fgColor); + graphics2D.setFont(axisNameFont); + + var title = graph.style().y().title(); + + var previousTransform = graphics2D.getTransform(); + graphics2D.rotate(Math.toRadians(-90), + yAxisNameCenterOffset.width(), + yAxisNameCenterOffset.height() + ); + graphics2D.fill(generateShapeFromText(graphics2D, + title, + yAxisNameCenterOffset.width() - axisNameFontMetrics.stringWidth(title) / 2d, + yAxisNameCenterOffset.height() + axisNameFontMetrics.getHeight() / 2d - axisNameFontMetrics.getDescent() + )); + graphics2D.setTransform(previousTransform); + } + + private static void renderYAxisValueLabels(Graphics2D graphics2D, + Graph graph, + Font valuesFont, + FontMetrics valuesFontMetrics, + double yValueLineOffset, + int yValueLineLength, + List yLabels, + RasterSize yLabelsAreaSize, + RasterSize yValuesOffset, + BasicStroke defaultStroke) { + graphics2D.setFont(valuesFont); + graphics2D.setStroke(defaultStroke); + graphics2D.setColor(graph.style().colors().foreground().toColor()); + yLabels.forEach(label -> { + var lineStartOffsetY = yValuesOffset.height() + label.rasterOffset(); + var currentLineOffsetX = label.formattedText().isBlank() ? yValueLineLength / 3d : 0; + var currentLineLength = yValueLineLength + (label.formattedText().isBlank() ? -yValueLineLength / 3d : 0); + graphics2D.draw(new Line2D.Double(yValueLineOffset + currentLineOffsetX, + lineStartOffsetY, + yValueLineOffset + currentLineOffsetX + currentLineLength, + lineStartOffsetY + )); + graphics2D.fill(generateShapeFromText(graphics2D, + label.formattedText(), + yValuesOffset.width() + yLabelsAreaSize.width() - valuesFontMetrics.stringWidth(label.formattedText()), + yValuesOffset.height() + label.rasterOffset() + valuesFontMetrics.getHeight() / 2d - valuesFontMetrics.getDescent() + )); + }); + } + + private static void renderXAxisName(Graphics2D graphics2D, + Graph graph, + RasterSize xAxisNameCenterOffset, + Font axisNameFont, + FontMetrics axisNameFontMetrics) { + var fgColor = graph.style().colors().foreground().toColor(); + graphics2D.setColor(fgColor); + graphics2D.setFont(axisNameFont); + + var title = graph.style().x().title(); + + graphics2D.fill(generateShapeFromText(graphics2D, + title, + xAxisNameCenterOffset.width() - axisNameFontMetrics.stringWidth(title) / 2d, + xAxisNameCenterOffset.height() + axisNameFontMetrics.getHeight() / 2d - axisNameFontMetrics.getDescent() + )); + } + + private static void renderXAxisValueLabels(Graphics2D graphics2D, + Graph graph, + Font valuesFont, + FontMetrics valuesFontMetrics, + double xValueLineOffset, + int xValueLineLength, + List xLabels, + RasterSize xValuesOffset, + BasicStroke defaultStroke) { + graphics2D.setFont(valuesFont); + graphics2D.setStroke(defaultStroke); + graphics2D.setColor(graph.style().colors().foreground().toColor()); + xLabels.forEach(label -> { + var lineStartOffsetX = xValuesOffset.width() + label.rasterOffset(); + var currentLineLength = label.formattedText().isBlank() ? xValueLineLength / 1.5d : xValueLineLength; + //noinspection SuspiciousNameCombination + graphics2D.draw(new Line2D.Double(lineStartOffsetX, xValueLineOffset, lineStartOffsetX, xValueLineOffset + currentLineLength)); + graphics2D.fill(generateShapeFromText(graphics2D, + label.formattedText(), + xValuesOffset.width() + label.rasterOffset() - valuesFontMetrics.stringWidth(label.formattedText()) / 2d, + xValuesOffset.height() + valuesFontMetrics.getHeight() + )); + }); + } + + private static void renderGraphBorders(Graphics2D graphics2D, + Graph graph, + RasterSize graphOffset, + RasterSize graphSize, + BasicStroke defaultStroke) { + var fgColor = graph.style().colors().foreground().toColor(); + graphics2D.setColor(fgColor); + graphics2D.setStroke(defaultStroke); + graphics2D.draw(new Rectangle2D.Double(graphOffset.width(), + graphOffset.height(), + graphSize.width(), + graphSize.height() + )); + } + + private static RasterSize computeYLabelsAreaSize(double graphHeight, FontMetrics valuesFontMetrics, List yLabels) { + double maxLabelWidth = 0d; + for (LabelWithOffset yLabel : yLabels) { + var currentMaxLabelWidth = valuesFontMetrics.stringWidth(yLabel.formattedText); + if (currentMaxLabelWidth > maxLabelWidth) { + maxLabelWidth = currentMaxLabelWidth; + } + } + + return new RasterSize(maxLabelWidth, graphHeight); + } + + record LabelWithOffset(double value, double rasterOffset, String formattedText) {} + + /** + * @return rendered labels + */ + private static List getXLabels(Graph graph, + double labelsAreaWidth, + FontMetrics valuesFontMetrics, + NiceScale scaleX) { + var bounds = graph.data().bounds(); + var minX = bounds.minX(); + var maxX = bounds.maxX(); + var format = graph.style().x().valueFormat(); + double singleRasterOffset = labelsAreaWidth / ((maxX - minX) / scaleX.getTickSpacing()); + + ArrayList labels = new ArrayList<>(); + + int i = 0; + double prevRasterLabelEndOffset = -Double.MAX_VALUE; + double currentRasterOffset = 0; + double currentValue = minX; + while (currentValue <= maxX && i < MAX_LABELS && (scaleX.getTickSpacing() > 0)) { + var formatted = format.apply(currentValue); + var stringWidth = valuesFontMetrics.stringWidth(formatted); + if (currentRasterOffset - stringWidth / 2d > prevRasterLabelEndOffset) { + labels.add(new LabelWithOffset(currentValue, currentRasterOffset, formatted)); + prevRasterLabelEndOffset = currentRasterOffset + stringWidth / 2d; + } else { + labels.add(new LabelWithOffset(currentValue, currentRasterOffset, "")); + } + + i++; + currentValue = minX + i * scaleX.getTickSpacing(); + currentRasterOffset = i * singleRasterOffset; + } + return labels; + } + + /** + * @return rendered labels + */ + private static List getYLabels(Graph graph, + double labelsAreaHeight, + FontMetrics valuesFontMetrics, + NiceScale scaleY) { + var bounds = graph.data().bounds(); + var minY = bounds.minY(); + var maxY = bounds.maxY(); + var format = graph.style().y().valueFormat(); + double singleRasterOffset = labelsAreaHeight / ((maxY - minY) / scaleY.getTickSpacing()); + double stringTop = valuesFontMetrics.getAscent(); + double stringBottom = valuesFontMetrics.getDescent(); + + ArrayList labels = new ArrayList<>(); + + int i = 0; + double prevRasterLabelEndOffset = Double.MAX_VALUE; + double currentRasterOffset = labelsAreaHeight; + double currentValue = minY; + while (currentValue <= maxY && i < MAX_LABELS && (scaleY.getTickSpacing() > 0)) { + if (currentRasterOffset + stringBottom < prevRasterLabelEndOffset) { + labels.add(new LabelWithOffset(currentValue, currentRasterOffset, format.apply(currentValue))); + prevRasterLabelEndOffset = currentRasterOffset - stringTop; + } else { + labels.add(new LabelWithOffset(currentValue, currentRasterOffset, "")); + } + + i++; + currentValue = minY + i * scaleY.getTickSpacing(); + currentRasterOffset = labelsAreaHeight - i * singleRasterOffset; + } + + return labels; + } + + public static Shape generateShapeFromText(Graphics2D graphics2D, String string, double x, double y) { + GlyphVector vector = graphics2D.getFont().createGlyphVector(graphics2D.getFontRenderContext(), string); + return vector.getOutline((float) x, (float) y);// - (float) vector.getVisualBounds().getY()); + } + + public interface AWTDrawer { + + void drawTo(Graphics2D graphics2D); + } +} diff --git a/src/main/java/it/cavallium/jlinegraph/Bezier.java b/src/main/java/it/cavallium/jlinegraph/Bezier.java new file mode 100644 index 0000000..16292e4 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/Bezier.java @@ -0,0 +1,118 @@ +package it.cavallium.jlinegraph; + +/* + * Copyright (c) 2005 David Benson + * + * See LICENSE file in distribution for licensing details of this source file + */ + +import java.awt.Point; +import java.awt.geom.Point2D; + +/** + * Interpolates given points by a bezier curve. The first + * and the last two points are interpolated by a quadratic + * bezier curve; the other points by a cubic bezier curve. + * + * Let p a list of given points and b the calculated bezier points, + * then one get the whole curve by: + * + * sharedPath.moveTo(p[0]) + * sharedPath.quadTo(b[0].x, b[0].getY(), p[1].x, p[1].getY()); + * + * for(int i = 2; i < p.length - 1; i++ ) { + * Point b0 = b[2*i-3]; + * Point b1 = b[2*i-2]; + * sharedPath.curveTo(b0.x, b0.getY(), b1.x, b1.getY(), p[i].x, p[i].getY()); + * } + * + * sharedPath.quadTo(b[b.length-1].x, b[b.length-1].getY(), p[n - 1].x, p[n - 1].getY()); + * + * @author krueger + */ +public class Bezier { + + private static final float AP = 0.5f; + private Point2D[] bPoints; + + /** + * Creates a new Bezier curve. + * @param points + */ + public Bezier(Point2D[] points) { + int n = points.length; + if (n < 3) { + // Cannot create bezier with less than 3 points + return; + } + bPoints = new Point[2 * (n - 2)]; + double paX, paY; + double pbX = points[0].getX(); + double pbY = points[0].getY(); + double pcX = points[1].getX(); + double pcY = points[1].getY(); + for (int i = 0; i < n - 2; i++) { + paX = pbX; + paY = pbY; + pbX = pcX; + pbY = pcY; + pcX = points[i + 2].getX(); + pcY = points[i + 2].getY(); + double abX = pbX - paX; + double abY = pbY - paY; + double acX = pcX - paX; + double acY = pcY - paY; + double lac = Math.sqrt(acX * acX + acY * acY); + acX = acX /lac; + acY = acY /lac; + + double proj = abX * acX + abY * acY; + proj = proj < 0 ? -proj : proj; + double apX = proj * acX; + double apY = proj * acY; + + double p1X = pbX - AP * apX; + double p1Y = pbY - AP * apY; + bPoints[2 * i] = new Point((int) p1X, (int) p1Y); + + acX = -acX; + acY = -acY; + double cbX = pbX - pcX; + double cbY = pbY - pcY; + proj = cbX * acX + cbY * acY; + proj = proj < 0 ? -proj : proj; + apX = proj * acX; + apY = proj * acY; + + double p2X = pbX - AP * apX; + double p2Y = pbY - AP * apY; + bPoints[2 * i + 1] = new Point((int) p2X, (int) p2Y); + } + } + + /** + * Returns the calculated bezier points. + * @return the calculated bezier points + */ + public Point2D[] getPoints() { + return bPoints; + } + + /** + * Returns the number of bezier points. + * @return number of bezier points + */ + public int getPointCount() { + return bPoints.length; + } + + /** + * Returns the bezier points at position i. + * @param i + * @return the bezier point at position i + */ + public Point2D getPoint(int i) { + return bPoints[i]; + } + +} \ No newline at end of file diff --git a/src/main/java/it/cavallium/jlinegraph/Color.java b/src/main/java/it/cavallium/jlinegraph/Color.java new file mode 100644 index 0000000..8a9e524 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/Color.java @@ -0,0 +1,36 @@ +package it.cavallium.jlinegraph; + +public record Color(float red, float green, float blue, float alpha) { + + public Color { + if (red < 0d || red > 1.0d) { + throw new IndexOutOfBoundsException(); + } + if (green < 0d || green > 1.0d) { + throw new IndexOutOfBoundsException(); + } + if (blue < 0d || blue > 1.0d) { + throw new IndexOutOfBoundsException(); + } + if (alpha < 0d || alpha > 1.0d) { + throw new IndexOutOfBoundsException(); + } + } + + public static Color fromRGB(int rgb) { + var col = new java.awt.Color(rgb); + return new Color(col.getRed() / 255f, col.getGreen() / 255f, col.getBlue() / 255f, 1); + } + + public java.awt.Color toColor() { + return new java.awt.Color(red, green, blue, alpha); + } + + public Color multiplyOpacity(float alpha) { + return new Color(red, green, blue, this.alpha * alpha); + } + + public Color overrideOpacity(float alpha) { + return new Color(red, green, blue, alpha); + } +} diff --git a/src/main/java/it/cavallium/jlinegraph/Graph.java b/src/main/java/it/cavallium/jlinegraph/Graph.java new file mode 100644 index 0000000..437c8dd --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/Graph.java @@ -0,0 +1,3 @@ +package it.cavallium.jlinegraph; + +public record Graph(String name, GraphData data, GraphStyle style) {} diff --git a/src/main/java/it/cavallium/jlinegraph/GraphAxisStyle.java b/src/main/java/it/cavallium/jlinegraph/GraphAxisStyle.java new file mode 100644 index 0000000..366f0ac --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/GraphAxisStyle.java @@ -0,0 +1,5 @@ +package it.cavallium.jlinegraph; + +import java.util.function.Function; + +public record GraphAxisStyle(String title, boolean show, Function valueFormat) {} diff --git a/src/main/java/it/cavallium/jlinegraph/GraphBounds.java b/src/main/java/it/cavallium/jlinegraph/GraphBounds.java new file mode 100644 index 0000000..1160dbf --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/GraphBounds.java @@ -0,0 +1,94 @@ +package it.cavallium.jlinegraph; + +import java.util.List; + +public record GraphBounds(double minX, double minY, double maxX, double maxY) { + + private static final GraphBounds EMPTY = new GraphBounds(0, 0, 0, 0); + + public static GraphBounds fromSeriesData(List seriesDataList, + boolean includeOriginX, + boolean includeOriginY) { + var merged = merge(seriesDataList + .stream() + .map(bound -> fromSeriesData(bound, includeOriginX, includeOriginY)) + .toList()); + return adjustZero(merged, includeOriginX, includeOriginY); + } + + private static GraphBounds adjustZero(GraphBounds bounds, boolean showZeroX, boolean showZeroY) { + double minX = bounds.minX(); + double minY = bounds.minY(); + double maxX = bounds.maxX(); + double maxY = bounds.maxY(); + if (showZeroY) { + minY = Math.min(0, bounds.minY()); + maxY = Math.max(0, bounds.maxY()); + } + if (showZeroX) { + minX = Math.min(0, bounds.minX()); + maxX = Math.max(0, bounds.maxX()); + } + return new GraphBounds(minX, minY, maxX, maxY); + } + + public static GraphBounds merge(List list) { + double minX = Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE; + double maxY = -Double.MAX_VALUE; + boolean empty = true; + for (GraphBounds graphBounds : list) { + if (empty) { + empty = false; + } + if (minX > graphBounds.minX()) { + minX = graphBounds.minX(); + } + if (maxX < graphBounds.maxX()) { + maxX = graphBounds.maxX(); + } + if (minY > graphBounds.minY()) { + minY = graphBounds.minY(); + } + if (maxY < graphBounds.maxY()) { + maxY = graphBounds.maxY(); + } + } + if (empty) { + return EMPTY; + } else { + return new GraphBounds(minX, minY, maxX, maxY); + } + } + + public static GraphBounds fromSeriesData(SeriesData seriesData, boolean showZeroX, boolean showZeroY) { + double minX = Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + double maxX = -Double.MAX_VALUE; + double maxY = -Double.MAX_VALUE; + boolean empty = true; + for (Vertex vertex : seriesData.vertices()) { + if (empty) { + empty = false; + } + if (minX > vertex.x()) { + minX = vertex.x(); + } + if (maxX < vertex.x()) { + maxX = vertex.x(); + } + if (minY > vertex.y()) { + minY = vertex.y(); + } + if (maxY < vertex.y()) { + maxY = vertex.y(); + } + } + if (empty) { + return EMPTY; + } else { + return adjustZero(new GraphBounds(minX, minY, maxX, maxY), showZeroX, showZeroY); + } + } +} diff --git a/src/main/java/it/cavallium/jlinegraph/GraphColors.java b/src/main/java/it/cavallium/jlinegraph/GraphColors.java new file mode 100644 index 0000000..d264477 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/GraphColors.java @@ -0,0 +1,7 @@ +package it.cavallium.jlinegraph; + +public record GraphColors(Color background, Color foreground) { + + public static GraphColors DARK = new GraphColors(new Color(0.1f, 0.1f, 0.1f, 1f), new Color(0.9f, 0.9f, 0.9f, 1f)); + public static GraphColors LIGHT = new GraphColors(new Color(1.0f, 1.0f, 1.0f, 1f), new Color(0.0f, 0.0f, 0.0f, 1f)); +} diff --git a/src/main/java/it/cavallium/jlinegraph/GraphData.java b/src/main/java/it/cavallium/jlinegraph/GraphData.java new file mode 100644 index 0000000..e5b2d93 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/GraphData.java @@ -0,0 +1,10 @@ +package it.cavallium.jlinegraph; + +import java.util.List; + +public record GraphData(List series, GraphBounds bounds) { + + public GraphData(List series) { + this(series, GraphBounds.fromSeriesData(series, false, false)); + } +} diff --git a/src/main/java/it/cavallium/jlinegraph/GraphFonts.java b/src/main/java/it/cavallium/jlinegraph/GraphFonts.java new file mode 100644 index 0000000..5bdcaa4 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/GraphFonts.java @@ -0,0 +1,3 @@ +package it.cavallium.jlinegraph; + +public record GraphFonts(double global, double axisName, double seriesName, double valueLabel) {} diff --git a/src/main/java/it/cavallium/jlinegraph/GraphStyle.java b/src/main/java/it/cavallium/jlinegraph/GraphStyle.java new file mode 100644 index 0000000..76512fd --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/GraphStyle.java @@ -0,0 +1,6 @@ +package it.cavallium.jlinegraph; + +import java.util.List; + +public record GraphStyle(List seriesStyles, GraphAxisStyle x, GraphAxisStyle y, GraphColors colors, + GraphFonts fonts, double strokeWidth, boolean showLegend) {} diff --git a/src/main/java/it/cavallium/jlinegraph/IGraphRenderer.java b/src/main/java/it/cavallium/jlinegraph/IGraphRenderer.java new file mode 100644 index 0000000..83a0264 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/IGraphRenderer.java @@ -0,0 +1,6 @@ +package it.cavallium.jlinegraph; + +public interface IGraphRenderer { + + T renderGraph(Graph graph, RasterSize size); +} diff --git a/src/main/java/it/cavallium/jlinegraph/NiceScale.java b/src/main/java/it/cavallium/jlinegraph/NiceScale.java new file mode 100644 index 0000000..8305402 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/NiceScale.java @@ -0,0 +1,111 @@ +package it.cavallium.jlinegraph; + +@SuppressWarnings("FieldCanBeLocal") +public class NiceScale { + + private double minPoint; + private double maxPoint; + private double maxTicks = 10; + private double tickSpacing; + private double range; + private double niceMin; + private double niceMax; + + /** + * Instantiates a new instance of the NiceScale class. + * + * @param min the minimum data point on the axis + * @param max the maximum data point on the axis + */ + public NiceScale(double min, double max) { + this.minPoint = min; + this.maxPoint = max; + calculate(); + } + + /** + * Calculate and update values for tick spacing and nice + * minimum and maximum data points on the axis. + */ + private void calculate() { + this.range = niceNum(maxPoint - minPoint, false); + this.tickSpacing = niceNum(range / (maxTicks - 1), true); + this.niceMin = + Math.floor(minPoint / tickSpacing) * tickSpacing; + this.niceMax = + Math.ceil(maxPoint / tickSpacing) * tickSpacing; + } + + /** + * Returns a "nice" number approximately equal to range Rounds + * the number if round = true Takes the ceiling if round = false. + * + * @param range the data range + * @param round whether to round the result + * @return a "nice" number to be used for the data range + */ + private double niceNum(double range, boolean round) { + double exponent; /** exponent of range */ + double fraction; /** fractional part of range */ + double niceFraction; /** nice, rounded fraction */ + + exponent = Math.floor(Math.log10(range)); + fraction = range / Math.pow(10, exponent); + + if (round) { + if (fraction < 1.5) + niceFraction = 1; + else if (fraction < 3) + niceFraction = 2; + else if (fraction < 7) + niceFraction = 5; + else + niceFraction = 10; + } else { + if (fraction <= 1) + niceFraction = 1; + else if (fraction <= 2) + niceFraction = 2; + else if (fraction <= 5) + niceFraction = 5; + else + niceFraction = 10; + } + + return niceFraction * Math.pow(10, exponent); + } + + /** + * Sets the minimum and maximum data points for the axis. + * + * @param minPoint the minimum data point on the axis + * @param maxPoint the maximum data point on the axis + */ + public void setMinMaxPoints(double minPoint, double maxPoint) { + this.minPoint = minPoint; + this.maxPoint = maxPoint; + calculate(); + } + + /** + * Sets maximum number of tick marks we're comfortable with + * + * @param maxTicks the maximum number of tick marks for the axis + */ + public void setMaxTicks(double maxTicks) { + this.maxTicks = maxTicks; + calculate(); + } + + public double getNiceMin() { + return niceMin; + } + + public double getNiceMax() { + return niceMax; + } + + public double getTickSpacing() { + return tickSpacing; + } +} \ No newline at end of file diff --git a/src/main/java/it/cavallium/jlinegraph/RasterSize.java b/src/main/java/it/cavallium/jlinegraph/RasterSize.java new file mode 100644 index 0000000..c0fcec9 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/RasterSize.java @@ -0,0 +1,3 @@ +package it.cavallium.jlinegraph; + +public record RasterSize(double width, double height) {} diff --git a/src/main/java/it/cavallium/jlinegraph/SeriesData.java b/src/main/java/it/cavallium/jlinegraph/SeriesData.java new file mode 100644 index 0000000..1fdbb29 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/SeriesData.java @@ -0,0 +1,5 @@ +package it.cavallium.jlinegraph; + +import java.util.List; + +public record SeriesData(List vertices, boolean isFunction, String name) {} diff --git a/src/main/java/it/cavallium/jlinegraph/SeriesStyle.java b/src/main/java/it/cavallium/jlinegraph/SeriesStyle.java new file mode 100644 index 0000000..8270181 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/SeriesStyle.java @@ -0,0 +1,19 @@ +package it.cavallium.jlinegraph; + +public record SeriesStyle(Color color, double pointsWeight, double lineWeight, double areaOpacity, double smoothness) { + + public SeriesStyle { + if (pointsWeight != 0 && (pointsWeight < 1d || pointsWeight > 4.0d)) { + throw new IndexOutOfBoundsException(); + } + if (lineWeight != 0 && (lineWeight < 1d || lineWeight > 4.0d)) { + throw new IndexOutOfBoundsException(); + } + if (areaOpacity < 0d || areaOpacity > 1.0d) { + throw new IndexOutOfBoundsException(); + } + if (smoothness < 0d || smoothness > 1.0d) { + throw new IndexOutOfBoundsException(); + } + } +} diff --git a/src/main/java/it/cavallium/jlinegraph/Vertex.java b/src/main/java/it/cavallium/jlinegraph/Vertex.java new file mode 100644 index 0000000..a51c4b5 --- /dev/null +++ b/src/main/java/it/cavallium/jlinegraph/Vertex.java @@ -0,0 +1,3 @@ +package it.cavallium.jlinegraph; + +public record Vertex(double x, double y) {}