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&lt;groupName, filePath&gt; 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}