001package com.studentgui.apppages; 002 003import java.awt.BasicStroke; 004import java.awt.BorderLayout; 005import java.awt.Color; 006import java.awt.Dimension; 007import java.awt.Font; 008import java.util.List; 009import java.util.Random; 010import java.util.concurrent.ThreadLocalRandom; 011 012import javax.swing.JPanel; 013 014import org.jfree.chart.ChartFactory; 015import org.jfree.chart.ChartPanel; 016import org.jfree.chart.JFreeChart; 017import org.jfree.chart.annotations.XYPolygonAnnotation; 018import org.jfree.chart.axis.NumberAxis; 019import org.jfree.chart.plot.PlotOrientation; 020import org.jfree.chart.plot.XYPlot; 021import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer; 022import org.jfree.data.xy.XYSeries; 023import org.jfree.data.xy.XYSeriesCollection; 024 025/** 026 * Reusable JFreeChart-based line chart component for visualizing student assessment progress. 027 * 028 * <p>This component is shared across all assessment pages (Braille, Abacus, iOS, ScreenReader, etc.) 029 * to display time-series data showing skill progression over multiple sessions. It supports three 030 * primary visualization modes:</p> 031 * 032 * <ul> 033 * <li><b>Single-chart mode:</b> {@link #updateWithData(java.util.List)} - Plots all skills on one 034 * chart with historical sessions in gray and the latest session highlighted in black</li> 035 * <li><b>Grouped mode (session indices):</b> {@link #updateWithGroupedData(java.util.List, String[])} - 036 * Creates multiple stacked charts, one per phase group (determined by part code prefix like "P1", "P2")</li> 037 * <li><b>Grouped mode (chronological dates):</b> {@link #updateWithGroupedDataByDate(java.util.List, java.util.List, String[], String[])} - 038 * Plots grouped data with actual dates on the X-axis for true time-series visualization</li> 039 * </ul> 040 * 041 * <p><b>Visual Design and Rendering:</b></p> 042 * <ul> 043 * <li><b>Background bands:</b> Colored horizontal bands indicate score ranges to aid interpretation: 044 * <ul> 045 * <li><span style="color:red;">Red band</span>: -0.25 to 0.5 (minimal/no proficiency)</li> 046 * <li><span style="color:orange;">Orange bands</span>: 0.5\u20131.5, 1.5\u20132.5 (emerging skills)</li> 047 * <li><span style="color:yellow;">Yellow band</span>: 2.5\u20133.5 (developing proficiency)</li> 048 * <li><span style="color:green;">Green band</span>: 3.5\u20134.5 (mastery/proficient)</li> 049 * </ul> 050 * </li> 051 * <li><b>Rendering jitter:</b> A configurable visual jitter of ±{@value #JITTER_AMPLITUDE} is applied 052 * to plotted points via {@link #addJitter(double)} to reveal overlapping data points. This is a 053 * display-only transformation and does not modify persisted values. Jitter can be: 054 * <ul> 055 * <li>Enabled/disabled via {@link #setJitterEnabled(boolean)}</li> 056 * <li>Made deterministic (for testing) via {@link #setJitterDeterministic(boolean)} and {@link #setJitterSeed(Long)}</li> 057 * <li>Configured via {@link com.studentgui.apphelpers.Settings} keys: "jitter.enabled", "jitter.deterministic", "jitter.seed"</li> 058 * </ul> 059 * </li> 060 * <li><b>Color palette:</b> Consistent color-blind friendly palette used for series rendering: 061 * <ul> 062 * <li>{@link #PALETTE_HEX}: Hex color strings for HTML legend generation (8 colors)</li> 063 * <li>{@link #PALETTE}: AWT Color objects for JFreeChart rendering (8 colors matching PALETTE_HEX)</li> 064 * </ul> 065 * </li> 066 * </ul> 067 * 068 * <p><b>Typical Workflow for Assessment Pages:</b></p> 069 * <ol> 070 * <li>Page fetches recent sessions from database via {@link com.studentgui.apphelpers.Database#fetchLatestAssessmentResultsWithDates}</li> 071 * <li>Page calls {@link #updateWithGroupedDataByDate(java.util.List, java.util.List, String[], String[])} to populate chart</li> 072 * <li>On submit, page calls {@link #saveGroupedCharts(java.nio.file.Path, String, int, int)} to export PNG images</li> 073 * <li>Page generates Markdown/HTML reports linking to the exported plots</li> 074 * </ol> 075 * 076 * <p><b>Export and Persistence:</b></p> 077 * <ul> 078 * <li>{@link #saveGroupedCharts(java.nio.file.Path, String, int, int)} - Exports each phase group as a separate PNG file</li> 079 * <li>{@link #saveChart(java.nio.file.Path, int, int)} - Exports the single main chart (when not in grouped mode)</li> 080 * <li>Returns Map<groupName, filePath> for use in report generation</li> 081 * </ul> 082 * 083 * <p><b>Accessibility:</b></p> 084 * <ul> 085 * <li>ChartPanel accessible name set to "Skill progression chart"</li> 086 * <li>Tooltips enabled showing coordinate values on hover</li> 087 * <li>Keyboard navigation supported through JFreeChart's default ChartPanel behavior</li> 088 * </ul> 089 * 090 * <p><b>Settings Integration:</b> Implements {@link com.studentgui.app.SettingsChangeListener} to respond 091 * to jitter configuration changes at runtime without requiring application restart.</p> 092 * 093 * @see com.studentgui.apphelpers.Database#fetchLatestAssessmentResultsWithDates 094 * @see com.studentgui.app.SettingsChangeListener 095 * @see org.jfree.chart.JFreeChart 096 * @see org.jfree.chart.ChartPanel 097 */ 098public class JLineGraph extends JPanel implements com.studentgui.app.SettingsChangeListener { 099 private static final long serialVersionUID = 1L; 100 private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(JLineGraph.class); 101 /** The dataset containing XY series for historical and latest sessions. */ 102 private final XYSeriesCollection lineDataset; 103 /** The JFreeChart instance used to render the plot. */ 104 private final JFreeChart chart; 105 /** Panel that embeds the chart and provides UI features. */ 106 private final ChartPanel chartPanel; 107 /** When rendering grouped charts we place multiple ChartPanels in this container. */ 108 private javax.swing.JPanel multiChartContainer; 109 /** Domain axis used to customise X-axis labels and range. */ 110 private final NumberAxis xAxis; 111 /** Expected number of skill columns per session. */ 112 private static final int NUMBER_OF_SKILLS = 28; // Adjust as needed 113 /** Jitter amplitude (plus/minus) applied to plotted data points. */ 114 private static final double JITTER_AMPLITUDE = 0.10d; 115 116 /** Whether rendering jitter is currently enabled. Default: true. */ 117 private boolean jitterEnabled = true; 118 /** When true, use a deterministic java.util.Random seeded RNG instead of ThreadLocalRandom. */ 119 private boolean jitterDeterministic = false; 120 /** Optional seed used when deterministic jitter is enabled. */ 121 private Long jitterSeed = null; 122 /** Cached Random instance when deterministic mode is enabled. */ 123 private Random deterministicRandom = null; 124 125 /** 126 * Add a small random jitter within +/- JITTER_AMPLITUDE to the provided value. 127 * When jitter is disabled this returns the original value unchanged. 128 */ 129 private double addJitter(final double v) { 130 if (!jitterEnabled) { 131 return v; 132 } 133 try { 134 if (jitterDeterministic) { 135 if (deterministicRandom == null) { 136 long seed = jitterSeed == null ? 0L : jitterSeed.longValue(); 137 deterministicRandom = new Random(seed); 138 } 139 double r = deterministicRandom.nextDouble() * 2.0 - 1.0; // -1..1 140 return v + (r * JITTER_AMPLITUDE); 141 } else { 142 return v + ThreadLocalRandom.current().nextDouble(-JITTER_AMPLITUDE, JITTER_AMPLITUDE); 143 } 144 } catch (Throwable t) { 145 // In the unlikely event RNG is unavailable, fall back to no jitter 146 return v; 147 } 148 } 149 /** Public color palette (hex) for HTML legends and consistency across pages. */ 150 public static final String[] PALETTE_HEX = new String[] { 151 "#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02","#a6761d","#666666" 152 }; 153 /** Public color palette as AWT Color objects for chart rendering. */ 154 public static final java.awt.Color[] PALETTE = new java.awt.Color[] { 155 new java.awt.Color(0x1b9e77), 156 new java.awt.Color(0xd95f02), 157 new java.awt.Color(0x7570b3), 158 new java.awt.Color(0xe7298a), 159 new java.awt.Color(0x66a61e), 160 new java.awt.Color(0xe6ab02), 161 new java.awt.Color(0xa6761d), 162 new java.awt.Color(0x666666) 163 }; 164 165 /** 166 * Create a new JLineGraph with default styling and an empty dataset. 167 */ 168 public JLineGraph() { 169 setLayout(new BorderLayout()); 170 lineDataset = new XYSeriesCollection(); 171 172 // Create a chart 173 chart = ChartFactory.createXYLineChart( 174 "Skill Progression", 175 "Skills", 176 "Value", 177 lineDataset, 178 PlotOrientation.VERTICAL, 179 true, 180 true, 181 false 182 ); 183 184 // Customize the plot 185 XYPlot plot = chart.getXYPlot(); 186 plot.setBackgroundPaint(Color.WHITE); 187 plot.setDomainGridlinePaint(Color.GRAY); 188 plot.setRangeGridlinePaint(Color.GRAY); 189 190 // Set axis ranges 191 xAxis = (NumberAxis) plot.getDomainAxis(); 192 xAxis.setRange(0, NUMBER_OF_SKILLS + 1); 193 NumberAxis yAxis = (NumberAxis) plot.getRangeAxis(); 194 yAxis.setRange(-0.25, 4.25); 195 196 // Create background bands 197 addBackgroundBands(plot); 198 199 chartPanel = new ChartPanel(chart); 200 chartPanel.setPreferredSize(new Dimension(800, 600)); 201 chartPanel.getAccessibleContext().setAccessibleName("Skill progression chart"); 202 chartPanel.setToolTipText("Skill progression chart showing historical and latest values"); 203 add(chartPanel, BorderLayout.CENTER); 204 multiChartContainer = null; 205 206 // Set custom X-axis labels 207 updateXAxisLabels(); 208 // Apply any persisted settings at creation time 209 try { 210 settingsChanged(); 211 } catch (Throwable t) { 212 // ignore any issues reading settings at startup 213 } 214 } 215 216 @Override 217 /** 218 * Load chart-related preferences from the global Settings store and apply 219 * them to this graph instance. Currently this updates jitter-related 220 * configuration such as enabled/deterministic flags and the optional seed. 221 */ 222 223 public void settingsChanged() { 224 try { 225 String je = com.studentgui.apphelpers.Settings.get("jitter.enabled", String.valueOf(this.jitterEnabled)); 226 setJitterEnabled("true".equalsIgnoreCase(je)); 227 String jd = com.studentgui.apphelpers.Settings.get("jitter.deterministic", String.valueOf(this.jitterDeterministic)); 228 setJitterDeterministic("true".equalsIgnoreCase(jd)); 229 String s = com.studentgui.apphelpers.Settings.get("jitter.seed", this.jitterSeed == null ? "" : String.valueOf(this.jitterSeed)); 230 if (s == null || s.trim().isEmpty()) { 231 setJitterSeed(null); 232 } else { 233 try { 234 long v = Long.parseLong(s.trim()); 235 setJitterSeed(Long.valueOf(v)); 236 } catch (NumberFormatException nfe) { 237 setJitterSeed(null); 238 } 239 } 240 // reset cached RNG so seed/cfg takes effect 241 this.deterministicRandom = null; 242 if (chart != null) { 243 chart.fireChartChanged(); 244 } 245 if (chartPanel != null) { 246 chartPanel.repaint(); 247 } 248 } catch (Throwable t) { 249 LOG.debug("Failed applying settings: {}", t.toString()); 250 } 251 } 252 253 /** 254 * Add lightly-colored horizontal bands to the plot to indicate score 255 * ranges. 256 */ 257 private void addBackgroundBands(final XYPlot plot) { 258 // Use the generic band painter to draw the requested bands across the 259 // full X domain of the main chart. 260 double left = 0.0; 261 double right = NUMBER_OF_SKILLS + 1; 262 addHorizontalBands(plot, left, right); 263 } 264 265 /** 266 * Add horizontal background bands to the provided plot between left and right 267 * X coordinates. Bands follow the requested ranges: 268 * red = -0.25..0.5, orange = 0.5..1.5, orange = 1.5..2.5, yellow = 2.5..3.5, 269 * green = 3.5..4.5 270 */ 271 private void addHorizontalBands(final XYPlot plot, final double left, final double right) { 272 try { 273 java.awt.Color red = new java.awt.Color(255, 0, 0, 40); 274 java.awt.Color orange = new java.awt.Color(255, 165, 0, 40); 275 java.awt.Color orange2 = new java.awt.Color(255, 140, 0, 40); 276 java.awt.Color yellow = new java.awt.Color(255, 255, 0, 40); 277 java.awt.Color green = new java.awt.Color(0, 255, 0, 40); 278 279 double[][] bands = new double[][]{ 280 { -0.25, 0.5 }, 281 { 0.5, 1.5 }, 282 { 1.5, 2.5 }, 283 { 2.5, 3.5 }, 284 { 3.5, 4.5 } 285 }; 286 java.awt.Color[] colors = new java.awt.Color[] { red, orange, orange2, yellow, green }; 287 288 for (int i = 0; i < bands.length; i++) { 289 double low = bands[i][0]; 290 double high = bands[i][1]; 291 double[] coords = new double[] { left, low, right, low, right, high, left, high }; 292 plot.addAnnotation(new XYPolygonAnnotation(coords, null, null, colors[i])); 293 } 294 } catch (Throwable t) { 295 LOG.debug("Unable to add horizontal bands: {}", t.toString()); 296 } 297 } 298 299 /** 300 * Enable or disable rendering jitter at runtime. 301 * @param enabled true to enable jitter, false to draw raw values 302 */ 303 public void setJitterEnabled(final boolean enabled) { 304 this.jitterEnabled = enabled; 305 } 306 307 /** 308 * Query whether rendering jitter is currently enabled. 309 * 310 * @return true when jitter is enabled, false otherwise 311 */ 312 public boolean isJitterEnabled() { 313 return this.jitterEnabled; 314 } 315 316 /** 317 * Enable/disable deterministic (seeded) jitter. 318 * When enabled, jitter will be generated from a java.util.Random seeded 319 * with {@link #jitterSeed} (or 0 when seed is null). 320 * 321 * @param deterministic true to use a seeded RNG, false to use non-deterministic RNG 322 */ 323 public void setJitterDeterministic(final boolean deterministic) { 324 this.jitterDeterministic = deterministic; 325 this.deterministicRandom = null; // reset instance so seed takes effect 326 } 327 328 /** 329 * Query whether deterministic jitter is enabled. 330 * 331 * @return true when deterministic (seeded) jitter is enabled 332 */ 333 public boolean isJitterDeterministic() { 334 return this.jitterDeterministic; 335 } 336 337 /** 338 * Set the seed used when deterministic jitter is enabled. Pass null to 339 * clear the seed (will use 0 when a deterministic RNG is created). 340 * 341 * @param seed seed value or null to clear 342 */ 343 public void setJitterSeed(final Long seed) { 344 this.jitterSeed = seed; 345 this.deterministicRandom = null; 346 } 347 348 /** 349 * Return the currently configured jitter seed or null when unset. 350 * 351 * @return configured seed value or null when not set 352 */ 353 public Long getJitterSeed() { 354 return this.jitterSeed; 355 } 356 357 /** 358 * Replace the current dataset with the provided list of skill value 359 * series. Each inner list represents a single session and must contain 360 * NUMBER_OF_SKILLS entries. 361 * 362 * @param allSkillValues list of sessions where each session is a list of 363 * integer skill values (older sessions first) 364 */ 365 public void updateWithData(final List<List<Integer>> allSkillValues) { 366 LOG.debug("updateWithData called with {} rows", allSkillValues == null ? 0 : allSkillValues.size()); 367 if (allSkillValues == null || allSkillValues.isEmpty()) { 368 return; 369 } 370 // Fallback to existing single-chart behavior 371 lineDataset.removeAllSeries(); 372 XYLineAndShapeRenderer renderer = new XYLineAndShapeRenderer(); 373 374 // Add historical data series (each prior session as a separate series) 375 for (int s = 0; s < allSkillValues.size() - 1; s++) { 376 XYSeries hs = new XYSeries("S" + s); 377 List<Integer> skillValues = allSkillValues.get(s); 378 if (skillValues == null) { 379 continue; 380 } 381 for (int j = 0; j < skillValues.size(); j++) { 382 Integer v = skillValues.get(j); 383 double y = (double) (v == null ? 0 : v); 384 hs.add(j + 1, addJitter(y)); 385 } 386 lineDataset.addSeries(hs); 387 renderer.setSeriesPaint(s, Color.GRAY); 388 renderer.setSeriesStroke(s, new BasicStroke(2.0f)); 389 renderer.setSeriesShapesVisible(s, false); 390 } 391 392 // Latest session 393 XYSeries latestSeries = new XYSeries("Latest"); 394 List<Integer> latestSkillValues = allSkillValues.get(allSkillValues.size() - 1); 395 if (latestSkillValues != null) { 396 for (int i = 0; i < latestSkillValues.size(); i++) { 397 Integer v = latestSkillValues.get(i); 398 double y = (double) (v == null ? 0 : v); 399 latestSeries.add(i + 1, addJitter(y)); 400 } 401 } 402 lineDataset.addSeries(latestSeries); 403 int latestIndex = lineDataset.getSeriesCount() - 1; 404 renderer.setSeriesPaint(latestIndex, Color.BLACK); 405 renderer.setSeriesStroke(latestIndex, new BasicStroke(3f)); 406 renderer.setSeriesShapesVisible(latestIndex, true); 407 renderer.setSeriesShape(latestIndex, new java.awt.geom.Ellipse2D.Double(-6, -6, 12, 12)); 408 409 chart.getXYPlot().setDataset(lineDataset); 410 chart.getXYPlot().setRenderer(renderer); 411 // Ensure Y axis range and ticks are consistent across charts 412 try { 413 NumberAxis y = (NumberAxis) chart.getXYPlot().getRangeAxis(); 414 y.setRange(-0.25, 4.25); 415 y.setTickUnit(new org.jfree.chart.axis.NumberTickUnit(1)); 416 } catch (ClassCastException ignored) { 417 // if range axis isn't a NumberAxis, ignore 418 } 419 chart.fireChartChanged(); 420 chartPanel.repaint(); 421 } 422 423 /** 424 * Update the component with grouped plots. Each group is determined by the 425 * prefix of the part code (e.g. 'P1' from 'P1_1'). For each group we render 426 * a separate small chart stacked vertically. 427 * 428 * @param allSkillValues list of sessions (older first) where each session is a list of integer skill values 429 * @param partCodes array of part codes aligned with columns in each session row 430 */ 431 public void updateWithGroupedData(final List<List<Integer>> allSkillValues, final String[] partCodes) { 432 LOG.debug("updateWithGroupedData called with rows={} partCodes={}", allSkillValues == null ? 0 : allSkillValues.size(), partCodes == null ? 0 : partCodes.length); 433 // validate 434 if (partCodes == null || partCodes.length == 0 || allSkillValues == null || allSkillValues.isEmpty()) { 435 return; 436 } 437 438 // Build group -> indexes map preserving order of first occurrence 439 java.util.LinkedHashMap<String, java.util.List<Integer>> groups = new java.util.LinkedHashMap<>(); 440 for (int i = 0; i < partCodes.length; i++) { 441 String code = partCodes[i]; 442 String grp = code != null && code.contains("_") ? code.split("_")[0] : code; 443 groups.computeIfAbsent(grp, k -> new java.util.ArrayList<>()).add(i); 444 } 445 446 // Remove any single chart mode UI 447 removeAll(); 448 multiChartContainer = new javax.swing.JPanel(); 449 multiChartContainer.setLayout(new javax.swing.BoxLayout(multiChartContainer, javax.swing.BoxLayout.Y_AXIS)); 450 451 // For each group create a small chart 452 for (var entry : groups.entrySet()) { 453 String grp = entry.getKey(); 454 java.util.List<Integer> idxs = entry.getValue(); 455 XYSeriesCollection dataset = new XYSeriesCollection(); 456 // historical sessions: create one series per prior session 457 int sessions = allSkillValues.size(); 458 for (int s = 0; s < sessions; s++) { 459 XYSeries series = new XYSeries(s == sessions - 1 ? "Latest" : "S" + s); 460 List<Integer> sessionRow = allSkillValues.get(s); 461 for (int k = 0; k < idxs.size(); k++) { 462 int colIndex = idxs.get(k); 463 int x = k + 1; 464 Integer vv = (colIndex < sessionRow.size() ? sessionRow.get(colIndex) : null); 465 double y = (double) (vv == null ? 0 : vv); 466 series.add(x, addJitter(y)); 467 } 468 dataset.addSeries(series); 469 } 470 471 JFreeChart subchart = ChartFactory.createXYLineChart( 472 grp + " - " + (idxs.size()) + " items", 473 "Skill", 474 "Value", 475 dataset, 476 PlotOrientation.VERTICAL, 477 false, 478 true, 479 false 480 ); 481 XYPlot plot = subchart.getXYPlot(); 482 plot.setBackgroundPaint(Color.WHITE); 483 XYLineAndShapeRenderer renderer = new XYLineAndShapeRenderer(); 484 for (int s = 0; s < dataset.getSeriesCount(); s++) { 485 if (s == dataset.getSeriesCount() - 1) { 486 renderer.setSeriesPaint(s, Color.BLACK); 487 renderer.setSeriesStroke(s, new BasicStroke(2.5f)); 488 renderer.setSeriesShapesVisible(s, true); 489 renderer.setSeriesShape(s, new java.awt.geom.Ellipse2D.Double(-4, -4, 8, 8)); 490 } else { 491 renderer.setSeriesPaint(s, Color.GRAY); 492 renderer.setSeriesStroke(s, new BasicStroke(1.5f)); 493 renderer.setSeriesShapesVisible(s, false); 494 } 495 } 496 plot.setRenderer(renderer); 497 // Ensure Y axis range and ticks show 0..3 grid with a small lower padding for x-axis visibility 498 try { 499 NumberAxis yAxis = (NumberAxis) plot.getRangeAxis(); 500 yAxis.setRange(-0.25, 4.25); 501 yAxis.setTickUnit(new org.jfree.chart.axis.NumberTickUnit(1)); 502 } catch (ClassCastException cce) { 503 LOG.debug("Range axis is not a NumberAxis: {}", cce.toString()); 504 } 505 NumberAxis domain = (NumberAxis) plot.getDomainAxis(); 506 if (idxs.size() <= 1) { 507 // single-point chart: give a small visual range around the point 508 domain.setRange(0.5, 1.5); 509 } else { 510 domain.setRange(1, idxs.size()); 511 } 512 513 ChartPanel cp = new ChartPanel(subchart); 514 // Store the group id on the panel so callers can name files per-group 515 cp.setName(grp); 516 cp.setPreferredSize(new Dimension(800, Math.max(100, 40 * idxs.size()))); 517 cp.setMaximumSize(new Dimension(Integer.MAX_VALUE, cp.getPreferredSize().height)); 518 multiChartContainer.add(cp); 519 } 520 521 add(new javax.swing.JScrollPane(multiChartContainer), BorderLayout.CENTER); 522 revalidate(); 523 repaint(); 524 } 525 526 /** 527 * Plot grouped data over time. Dates are used as the X axis (oldest first). 528 * Each skill within a group is drawn as its own line (one series per skill) 529 * with point markers and a color-blind friendly palette. Legend placed 530 * in the upper-right corner. 531 * 532 * @param dates chronological list of session dates (oldest first) 533 * @param rows list of session rows where each row is a list of integer scores 534 * @param partCodes array of part codes aligned with the columns in each row 535 */ 536 public void updateWithGroupedDataByDate(final java.util.List<java.time.LocalDate> dates, final java.util.List<java.util.List<Integer>> rows, final String[] partCodes) { 537 // Backwards-compatible wrapper: use code strings as labels if caller didn't provide labels 538 String[] labels = partCodes == null ? null : partCodes.clone(); 539 updateWithGroupedDataByDate(dates, rows, partCodes, labels); 540 } 541 542 /** 543 * Plot grouped data over time with optional human-friendly labels. 544 * Each provided {@code partCodes} entry maps to a column index inside 545 * {@code rows} and (optionally) a friendly label supplied in 546 * {@code partLabels}. The dates list must be ordered oldest-first and 547 * must be parallel to the rows list. 548 * 549 * @param dates chronological list of session dates (oldest first) 550 * @param rows list of session rows where each row is a list of integer scores 551 * @param partCodes array of part codes aligned with the columns in each row 552 * @param partLabels optional human friendly labels parallel to {@code partCodes} 553 */ 554 public void updateWithGroupedDataByDate(final java.util.List<java.time.LocalDate> dates, final java.util.List<java.util.List<Integer>> rows, final String[] partCodes, final String[] partLabels) { 555 LOG.debug("updateWithGroupedDataByDate called with dates={} rows={} parts={}", dates == null ? 0 : dates.size(), rows == null ? 0 : rows.size(), partCodes == null ? 0 : partCodes.length); 556 if (dates == null || rows == null || partCodes == null) { 557 return; 558 } 559 // Build groups preserving order 560 java.util.LinkedHashMap<String, java.util.List<Integer>> groups = new java.util.LinkedHashMap<>(); 561 for (int i = 0; i < partCodes.length; i++) { 562 String code = partCodes[i]; 563 String grp = code != null && code.contains("_") ? code.split("_")[0] : code; 564 groups.computeIfAbsent(grp, k -> new java.util.ArrayList<>()).add(i); 565 } 566 567 // Remove any single chart mode UI 568 removeAll(); 569 multiChartContainer = new javax.swing.JPanel(); 570 multiChartContainer.setLayout(new javax.swing.BoxLayout(multiChartContainer, javax.swing.BoxLayout.Y_AXIS)); 571 572 // Color-blind friendly palette (ColorBrewer Set2-like) 573 java.awt.Color[] palette = new java.awt.Color[] { 574 new java.awt.Color(0x1b9e77), // green 575 new java.awt.Color(0xd95f02), // orange 576 new java.awt.Color(0x7570b3), // purple 577 new java.awt.Color(0xe7298a), // pink 578 new java.awt.Color(0x66a61e), // olive 579 new java.awt.Color(0xe6ab02), // mustard 580 new java.awt.Color(0xa6761d), // brown 581 new java.awt.Color(0x666666) // gray 582 }; 583 584 for (var entry : groups.entrySet()) { 585 String grp = entry.getKey(); 586 java.util.List<Integer> idxs = entry.getValue(); 587 org.jfree.data.time.TimeSeriesCollection dataset = new org.jfree.data.time.TimeSeriesCollection(); 588 589 // For each skill in the group, build a time series across dates 590 for (int k = 0; k < idxs.size(); k++) { 591 int colIndex = idxs.get(k); 592 String code = partCodes[colIndex]; 593 String human = (partLabels != null && partLabels.length > colIndex && partLabels[colIndex] != null) ? partLabels[colIndex] : code; 594 String seriesName = code + " - " + human; // legend shows code plus friendly label 595 org.jfree.data.time.TimeSeries ts = new org.jfree.data.time.TimeSeries(seriesName); 596 for (int r = 0; r < rows.size(); r++) { 597 java.time.LocalDate d = dates.get(r); 598 java.util.List<Integer> row = rows.get(r); 599 Integer vv = (colIndex < row.size()) ? row.get(colIndex) : null; 600 double val = (double) (vv == null ? 0 : vv); 601 org.jfree.data.time.Day day = new org.jfree.data.time.Day(java.util.Date.from(d.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant())); 602 ts.addOrUpdate(day, addJitter(val)); 603 } 604 dataset.addSeries(ts); 605 } 606 607 // Title: "Phase N Progression" when grp matches P<digit(s)> 608 String title = (grp != null && grp.startsWith("P") && grp.length() > 1) 609 ? ("Phase " + grp.substring(1) + " Progression") 610 : (grp + " progression"); 611 612 JFreeChart subchart = ChartFactory.createTimeSeriesChart( 613 title, 614 "Date", 615 "Value", 616 dataset, 617 true, 618 true, 619 false 620 ); 621 622 XYPlot plot = subchart.getXYPlot(); 623 plot.setBackgroundPaint(java.awt.Color.WHITE); 624 // Add colored horizontal bands behind the data using polygon annotations 625 try { 626 // Compute domain lower/upper bounds in millis for the current dataset if available 627 long domainLower = Long.MIN_VALUE; 628 long domainUpper = Long.MAX_VALUE; 629 if (!dates.isEmpty()) { 630 java.time.ZoneId zid = java.time.ZoneId.systemDefault(); 631 java.time.LocalDate first = dates.get(0); 632 java.time.LocalDate last = dates.get(dates.size() - 1).plusDays(4); 633 domainLower = java.util.Date.from(first.atStartOfDay(zid).toInstant()).getTime(); 634 domainUpper = java.util.Date.from(last.atStartOfDay(zid).toInstant()).getTime(); 635 } 636 double left = domainLower == Long.MIN_VALUE ? plot.getDomainAxis().getRange().getLowerBound() : domainLower; 637 double right = domainUpper == Long.MAX_VALUE ? plot.getDomainAxis().getRange().getUpperBound() : domainUpper; 638 // Use shared helper to draw bands in the domain coordinates (millis) 639 addHorizontalBands(plot, left, right); 640 } catch (Throwable t) { 641 LOG.debug("Unable to add background bands as annotations: {}", t.toString()); 642 } 643 org.jfree.chart.renderer.xy.XYLineAndShapeRenderer renderer = new org.jfree.chart.renderer.xy.XYLineAndShapeRenderer(true, true); 644 // assign colors and markers 645 for (int s = 0; s < dataset.getSeriesCount(); s++) { 646 java.awt.Color c = palette[s % palette.length]; 647 renderer.setSeriesPaint(s, c); 648 renderer.setSeriesStroke(s, new java.awt.BasicStroke(2.0f)); 649 renderer.setSeriesShapesVisible(s, true); 650 renderer.setSeriesShape(s, new java.awt.geom.Ellipse2D.Double(-3, -3, 6, 6)); 651 } 652 plot.setRenderer(renderer); 653 654 // Ensure Y axis range and ticks show 0..3 grid with a small lower padding for x-axis visibility 655 try { 656 NumberAxis yAxis = (NumberAxis) plot.getRangeAxis(); 657 yAxis.setRange(-0.25, 4.25); 658 yAxis.setTickUnit(new org.jfree.chart.axis.NumberTickUnit(1)); 659 } catch (ClassCastException cce) { 660 LOG.debug("Range axis is not a NumberAxis: {}", cce.toString()); 661 } 662 663 // Ensure Y axis range and ticks show 0..3 grid with a small lower padding for x-axis visibility 664 try { 665 org.jfree.chart.axis.DateAxis dateAxis = (org.jfree.chart.axis.DateAxis) plot.getDomainAxis(); 666 java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat("yyyyMMdd"); 667 dateAxis.setDateFormatOverride(fmt); 668 // Use the provided dates list to determine bounds (oldest first) 669 if (!dates.isEmpty()) { 670 java.time.ZoneId zid = java.time.ZoneId.systemDefault(); 671 java.time.LocalDate firstDate = dates.get(0); 672 java.time.LocalDate lastDate = dates.get(dates.size() - 1); 673 // pad 4 days on the right to provide visual breathing room 674 java.time.LocalDate paddedUpper = lastDate.plusDays(4); 675 java.util.Date lower = java.util.Date.from(firstDate.atStartOfDay(zid).toInstant()); 676 java.util.Date upper = java.util.Date.from(paddedUpper.atStartOfDay(zid).toInstant()); 677 dateAxis.setRange(lower, upper); 678 // one-day tick units so each datapoint maps to a single label 679 dateAxis.setTickUnit(new org.jfree.chart.axis.DateTickUnit(org.jfree.chart.axis.DateTickUnitType.DAY, 1)); 680 } 681 } catch (ClassCastException cce) { 682 LOG.debug("Domain axis is not a DateAxis: {}", cce.toString()); 683 } 684 685 // Place legend below the plot for clarity and allow it to show codes+labels 686 if (subchart.getLegend() != null) { 687 subchart.getLegend().setPosition(org.jfree.chart.ui.RectangleEdge.BOTTOM); 688 } 689 690 ChartPanel cp = new ChartPanel(subchart); 691 cp.setName(grp); 692 cp.setPreferredSize(new Dimension(1000, Math.max(180, 40 * idxs.size()))); 693 cp.setMaximumSize(new Dimension(Integer.MAX_VALUE, cp.getPreferredSize().height)); 694 multiChartContainer.add(cp); 695 } 696 697 add(new javax.swing.JScrollPane(multiChartContainer), BorderLayout.CENTER); 698 revalidate(); 699 repaint(); 700 } 701 702 /** 703 * Save each grouped subchart as an individual PNG file. The method writes 704 * files named {baseName}-{group}.png into the provided directory and 705 * returns a map of group -> written path. Caller must ensure grouped data 706 * has been rendered (updateWithGroupedData called) prior to invoking this. 707 * 708 * @param dir directory to write files into 709 * @param baseName base filename (no extension) to prefix each file 710 * @param width image width in pixels 711 * @param heightPerGroup per-group image height in pixels 712 * @return ordered map of group id to written file path 713 * @throws java.io.IOException on I/O error 714 */ 715 public java.util.Map<String, java.nio.file.Path> saveGroupedCharts(final java.nio.file.Path dir, final String baseName, final int width, final int heightPerGroup) throws java.io.IOException { 716 java.util.Map<String, java.nio.file.Path> out = new java.util.LinkedHashMap<>(); 717 if (dir == null) { 718 throw new java.io.IOException("output dir is null"); 719 } 720 java.nio.file.Files.createDirectories(dir); 721 if (multiChartContainer == null || multiChartContainer.getComponentCount() == 0) { 722 return out; 723 } 724 for (int i = 0; i < multiChartContainer.getComponentCount(); i++) { 725 java.awt.Component c = multiChartContainer.getComponent(i); 726 String grp = c.getName() != null ? c.getName() : String.valueOf(i+1); 727 int h = Math.max(100, heightPerGroup); 728 c.setSize(width, h); 729 c.doLayout(); 730 java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(width, h, java.awt.image.BufferedImage.TYPE_INT_ARGB); 731 java.awt.Graphics2D g = img.createGraphics(); 732 g.setColor(java.awt.Color.WHITE); 733 g.fillRect(0, 0, width, h); 734 c.paint(g); 735 g.dispose(); 736 java.nio.file.Path file = dir.resolve(baseName + "-" + grp + ".png"); 737 try (java.io.OutputStream os = java.nio.file.Files.newOutputStream(file); 738 javax.imageio.stream.ImageOutputStream ios = javax.imageio.ImageIO.createImageOutputStream(os)) { 739 boolean written = javax.imageio.ImageIO.write(img, "png", ios); 740 if (!written) { 741 throw new java.io.IOException("No ImageWriter for png"); 742 } 743 } 744 out.put(grp, file); 745 } 746 return out; 747 } 748 749 /** 750 * Show an empty grouped chart using the provided part codes. This will 751 * render one row of zeros sized to the number of parts so the UI shows 752 * grouped axes and placeholders even when no session data exists yet. 753 * 754 * @param partCodes array of part codes used to determine the number of columns 755 */ 756 public void showEmptyGrouped(final String[] partCodes) { 757 if (partCodes == null) { 758 return; 759 } 760 List<Integer> zeros = new java.util.ArrayList<>(java.util.Collections.nCopies(partCodes.length, 0)); 761 List<List<Integer>> rows = new java.util.ArrayList<>(); 762 rows.add(zeros); 763 updateWithGroupedData(rows, partCodes); 764 } 765 766 /** 767 * Save the current chart to a PNG file. If the chart is empty this will 768 * still export the rendered chart panel contents. 769 * 770 * @param outputPath path to write the PNG file to 771 * @param width image width in pixels 772 * @param height image height in pixels 773 * @throws java.io.IOException if writing fails 774 */ 775 public void saveChart(final java.nio.file.Path outputPath, final int width, final int height) throws java.io.IOException { 776 if (outputPath == null) { 777 throw new java.io.IOException("outputPath is null"); 778 } 779 java.nio.file.Path parent = outputPath.getParent(); 780 if (parent == null) { 781 parent = java.nio.file.Paths.get("."); 782 } 783 // Ensure parent directory exists 784 java.nio.file.Files.createDirectories(parent); 785 java.awt.image.BufferedImage img = null; 786 // If we are in grouped-chart mode, render the multiChartContainer component 787 if (multiChartContainer != null && multiChartContainer.getComponentCount() > 0) { 788 // Ensure layout sizes are applied 789 multiChartContainer.setSize(width, height); 790 multiChartContainer.doLayout(); 791 img = new java.awt.image.BufferedImage(width, height, java.awt.image.BufferedImage.TYPE_INT_ARGB); 792 java.awt.Graphics2D g = img.createGraphics(); 793 // paint background white to match chart look 794 g.setColor(java.awt.Color.WHITE); 795 g.fillRect(0, 0, width, height); 796 multiChartContainer.paint(g); 797 g.dispose(); 798 } else if (chart != null) { 799 img = chart.createBufferedImage(width, height); 800 } else { 801 throw new java.io.IOException("No chart available to render"); 802 } 803 804 try { 805 // Use an explicit OutputStream -> ImageOutputStream to avoid platform-specific ImageIO issues 806 try (java.io.OutputStream os = java.nio.file.Files.newOutputStream(outputPath); 807 javax.imageio.stream.ImageOutputStream ios = javax.imageio.ImageIO.createImageOutputStream(os)) { 808 boolean written = javax.imageio.ImageIO.write(img, "png", ios); 809 if (!written) { 810 throw new java.io.IOException("No ImageWriter available for format 'png'"); 811 } 812 } 813 } catch (java.io.IOException ioe) { 814 String diag = String.format("Failed saving chart to %s (parentExists=%b, parentWritable=%b, parentIsDir=%b)", 815 outputPath.toString(), java.nio.file.Files.exists(parent), java.nio.file.Files.isWritable(parent), java.nio.file.Files.isDirectory(parent)); 816 throw new java.io.IOException(diag, ioe); 817 } 818 } 819 820 private void updateXAxisLabels() { 821 // Generate labels for the X-axis 822 String[] skillLabels = new String[NUMBER_OF_SKILLS]; 823 int skillGroup = 1; 824 int skillNumber = 1; 825 for (int i = 0; i < NUMBER_OF_SKILLS; i++) { 826 skillLabels[i] = "Skill" + skillGroup + "-" + skillNumber; 827 skillNumber++; 828 if ((skillGroup == 1 && skillNumber > 6) || 829 (skillGroup == 2 && skillNumber > 4) || 830 (skillGroup == 3 && skillNumber > 11) || 831 (skillGroup == 4 && skillNumber > 7)) { 832 skillGroup++; 833 skillNumber = 1; 834 } 835 } 836 837 // Set the custom labels on the X-axis 838 NumberAxis domain = (NumberAxis) chart.getXYPlot().getDomainAxis(); 839 domain.setVerticalTickLabels(true); 840 domain.setTickLabelFont(new Font("SansSerif", Font.PLAIN, 8)); 841 domain.setTickUnit(new org.jfree.chart.axis.NumberTickUnit(1) { 842 @Override 843 /** 844 * Convert a numeric X-axis tick value into the corresponding skill 845 * label. The chart uses 1-based integer positions; this method maps 846 * that position (cast to an index) into the precomputed 847 * `skillLabels` array and returns an empty string for out-of-range 848 * values. 849 * 850 * @param value numeric tick value provided by the axis 851 * @return the skill label for the given tick, or an empty string 852 */ 853 854 public String valueToString(double value) { 855 int index = (int) value - 1; 856 if (index >= 0 && index < skillLabels.length) { 857 return skillLabels[index]; 858 } 859 return ""; 860 } 861 }); 862 } 863}