1717 */
1818package org .hortonmachine .database .addons .whetgeo ;
1919
20+ import java .awt .BasicStroke ;
2021import java .awt .BorderLayout ;
2122import java .awt .Color ;
23+ import java .awt .FlowLayout ;
2224import java .awt .Font ;
2325import java .text .DecimalFormat ;
2426import java .util .ArrayList ;
2527import java .util .Arrays ;
28+ import java .util .HashMap ;
2629import java .util .List ;
30+ import java .util .Map ;
2731
2832import javax .swing .BorderFactory ;
2933import javax .swing .BoxLayout ;
34+ import javax .swing .JCheckBox ;
3035import javax .swing .JLabel ;
3136import javax .swing .JPanel ;
3237import javax .swing .SwingConstants ;
3338
3439import org .hortonmachine .database .addons .whetgeo .WhetgeoStateChartData .DepthSeries ;
40+ import org .hortonmachine .database .addons .whetgeo .WhetgeoStateChartData .SwrcParams ;
3541import org .hortonmachine .gears .utils .colors .ColorUtilities ;
3642import org .jfree .chart .ChartPanel ;
3743import org .jfree .chart .JFreeChart ;
3844import org .jfree .chart .axis .DateAxis ;
3945import org .jfree .chart .axis .NumberAxis ;
4046import org .jfree .chart .plot .CombinedDomainXYPlot ;
47+ import org .jfree .chart .plot .ValueMarker ;
4148import org .jfree .chart .plot .XYPlot ;
4249import org .jfree .chart .renderer .PaintScale ;
4350import org .jfree .chart .renderer .xy .XYBarRenderer ;
4653import org .jfree .data .xy .DefaultIntervalXYDataset ;
4754import org .jfree .data .xy .DefaultXYZDataset ;
4855import org .jfree .data .xy .IntervalXYDataset ;
56+ import org .jfree .ui .RectangleAnchor ;
4957import org .jfree .ui .RectangleEdge ;
5058import org .jfree .ui .RectangleInsets ;
59+ import org .jfree .ui .TextAnchor ;
5160
5261/**
5362 * Builds the WHETGEO 1D state (Hovmoller-style) chart: an optional top/bottom
@@ -115,38 +124,66 @@ public static JPanel build( WhetgeoStateChartData data, String title ) {
115124 combinedPlot .setGap (12 );
116125 boolean hasChartRow = false ;
117126
127+ String topLabel = bcLabel ("Top Boundary Condition" , data .topBCType );
118128 double [] topDistinct = distinctSorted (data .topBCTimes .length > 0 ? data .topBCValues : new double [0 ]);
119129 if (data .topBCTimes .length > 0 && topDistinct .length > 1 ) {
120- combinedPlot .add (buildBCPlot ("Top Boundary Condition" , data .topBCTimes , data .topBCValues , TOP_BC_COLOR ), 1 );
130+ combinedPlot .add (buildBCPlot (topLabel , data .topBCTimes , data .topBCValues , TOP_BC_COLOR ), 1 );
121131 hasChartRow = true ;
122132 } else if (topDistinct .length == 1 ) {
123- addConstantValueRow (constantRows , "Top Boundary Condition" , topDistinct [0 ]);
133+ addConstantValueRow (constantRows , topLabel , topDistinct [0 ]);
124134 }
125135
136+ String bottomLabel = bcLabel ("Bottom Boundary Condition" , data .bottomBCType );
126137 double [] bottomDistinct = distinctSorted (data .bottomBCTimes .length > 0 ? data .bottomBCValues : new double [0 ]);
127138 if (data .bottomBCTimes .length > 0 && bottomDistinct .length > 1 ) {
128- combinedPlot .add (buildBCPlot ("Bottom Boundary Condition" , data .bottomBCTimes , data .bottomBCValues ,
129- BOTTOM_BC_COLOR ), 1 );
139+ combinedPlot .add (buildBCPlot (bottomLabel , data .bottomBCTimes , data .bottomBCValues , BOTTOM_BC_COLOR ), 1 );
130140 hasChartRow = true ;
131141 } else if (bottomDistinct .length == 1 ) {
132- addConstantValueRow (constantRows , "Bottom Boundary Condition" , bottomDistinct [0 ]);
142+ addConstantValueRow (constantRows , bottomLabel , bottomDistinct [0 ]);
133143 }
134144
145+ List <LayerBoundary > layerBoundaries = computeLayerBoundaries (data );
146+ List <XYPlot > heatmapPlots = new ArrayList <>();
147+
135148 List <PaintScaleLegend > legends = new ArrayList <>();
136149 for ( int i = 0 ; i < data .depthSeries .size (); i ++ ) {
137150 DepthSeries series = data .depthSeries .get (i );
138151 Color [] ramp = HEATMAP_RAMPS [i % HEATMAP_RAMPS .length ];
139152 double [] bounds = valueBounds (series .values );
140153 PaintScale scale = new TwoColorPaintScale (bounds [0 ], bounds [1 ], ramp [0 ], ramp [1 ]);
141154
142- combinedPlot .add (buildHeatmapPlot (series , scale ), 2 );
155+ XYPlot heatmapPlot = buildHeatmapPlot (series , scale , layerBoundaries );
156+ heatmapPlots .add (heatmapPlot );
157+ combinedPlot .add (heatmapPlot , 2 );
143158 hasChartRow = true ;
144159 legends .add (buildLegend (series , scale , bounds ));
145160 }
146161
147- JPanel panel = new JPanel (new BorderLayout ());
162+ JPanel topArea = new JPanel ();
163+ topArea .setLayout (new BoxLayout (topArea , BoxLayout .Y_AXIS ));
164+ if (!layerBoundaries .isEmpty ()) {
165+ JCheckBox showAnnotationsCheck = new JCheckBox ("Show layer annotations" , true );
166+ showAnnotationsCheck .setAlignmentX (JCheckBox .LEFT_ALIGNMENT );
167+ showAnnotationsCheck .addActionListener (e -> {
168+ boolean show = showAnnotationsCheck .isSelected ();
169+ for ( XYPlot plot : heatmapPlots ) {
170+ plot .clearRangeMarkers ();
171+ if (show ) {
172+ addLayerBoundaryMarkers (plot , layerBoundaries );
173+ }
174+ }
175+ });
176+ JPanel checkRow = new JPanel (new FlowLayout (FlowLayout .LEFT , 8 , 4 ));
177+ checkRow .add (showAnnotationsCheck );
178+ topArea .add (checkRow );
179+ }
148180 if (constantRows .getComponentCount () > 0 ) {
149- panel .add (constantRows , BorderLayout .NORTH );
181+ topArea .add (constantRows );
182+ }
183+
184+ JPanel panel = new JPanel (new BorderLayout ());
185+ if (topArea .getComponentCount () > 0 ) {
186+ panel .add (topArea , BorderLayout .NORTH );
150187 }
151188 if (hasChartRow ) {
152189 JFreeChart chart = new JFreeChart (title , JFreeChart .DEFAULT_TITLE_FONT , combinedPlot , false );
@@ -185,7 +222,7 @@ private static XYPlot buildBCPlot( String axisLabel, long[] times, double[] valu
185222 return plot ;
186223 }
187224
188- private static XYPlot buildHeatmapPlot ( DepthSeries series , PaintScale scale ) {
225+ private static XYPlot buildHeatmapPlot ( DepthSeries series , PaintScale scale , List < LayerBoundary > layerBoundaries ) {
189226 double [] xValues = series .times .length > 0 ? toDoubleArray (series .times ) : new double [0 ];
190227 double [] yValues = series .eta ;
191228 double [] zValues = series .values ;
@@ -210,9 +247,84 @@ private static XYPlot buildHeatmapPlot( DepthSeries series, PaintScale scale ) {
210247 plot .setRenderer (0 , renderer );
211248 plot .setRangeAxis (0 , depthAxis );
212249 plot .mapDatasetToRangeAxis (0 , 0 );
250+
251+ addLayerBoundaryMarkers (plot , layerBoundaries );
252+
213253 return plot ;
214254 }
215255
256+ /** Draws one dashed, labeled {@link ValueMarker} per layer boundary onto {@code plot} -
257+ * factored out so the "Show layer annotations" checkbox can call it again after {@code
258+ * plot.clearRangeMarkers()} without rebuilding the whole chart. */
259+ private static void addLayerBoundaryMarkers ( XYPlot plot , List <LayerBoundary > layerBoundaries ) {
260+ for ( LayerBoundary boundary : layerBoundaries ) {
261+ ValueMarker marker = new ValueMarker (boundary .topEta );
262+ marker .setPaint (Color .DARK_GRAY );
263+ marker .setStroke (
264+ new BasicStroke (1f , BasicStroke .CAP_BUTT , BasicStroke .JOIN_MITER , 1f , new float []{4f , 4f }, 0f ));
265+ marker .setLabel (boundary .label );
266+ marker .setLabelFont (marker .getLabelFont ().deriveFont (Font .PLAIN , 9f ));
267+ marker .setLabelPaint (Color .DARK_GRAY );
268+ marker .setLabelAnchor (RectangleAnchor .TOP_LEFT );
269+ marker .setLabelTextAnchor (TextAnchor .BOTTOM_LEFT );
270+ marker .setLabelOffset (new RectangleInsets (2 , 4 , 2 , 4 ));
271+ plot .addRangeMarker (marker );
272+ }
273+ }
274+
275+ /**
276+ * One value per internal transition between parameter sets in {@link
277+ * WhetgeoStateChartData#gridEta}, each labeled with the SWRC parameters of the
278+ * layer above that boundary - empty if the output wasn't written with {@code
279+ * parameter_id}/{@code output_swrc_parameters} (see {@code
280+ * Whetgeo1DOutputsHandler}), so older outputs just render without annotations.
281+ *
282+ * <p>
283+ * {@code gridEta} holds cell *centers*, not layer edges, so the boundary itself
284+ * is the midpoint between the last cell of the lower layer and the first cell
285+ * of the layer above it - not either cell's own eta - otherwise the drawn line
286+ * lands half a cell-thickness away from the real boundary (e.g. -1.01 instead
287+ * of the true -1.00 for two 0.02 m-thick layers).
288+ */
289+ private static List <LayerBoundary > computeLayerBoundaries ( WhetgeoStateChartData data ) {
290+ List <LayerBoundary > boundaries = new ArrayList <>();
291+ if (data .gridParameterID .length != data .gridEta .length || data .swrcParameters .isEmpty ()) {
292+ return boundaries ;
293+ }
294+ Map <Integer , SwrcParams > byId = new HashMap <>();
295+ for ( SwrcParams p : data .swrcParameters ) {
296+ byId .put (p .id , p );
297+ }
298+
299+ // gridEta is ascending; a boundary exists wherever parameterID changes
300+ // between two consecutive cells
301+ for ( int i = 1 ; i < data .gridEta .length ; i ++ ) {
302+ int lowerID = data .gridParameterID [i - 1 ];
303+ int upperID = data .gridParameterID [i ];
304+ if (lowerID == upperID ) {
305+ continue ;
306+ }
307+ SwrcParams p = byId .get (upperID );
308+ if (p == null ) {
309+ continue ;
310+ }
311+ double boundaryEta = (data .gridEta [i - 1 ] + data .gridEta [i ]) / 2.0 ;
312+ String label = String .format ("θS=%.3f θR=%.3f Ks=%.2e" , p .thetaS , p .thetaR , p .ks );
313+ boundaries .add (new LayerBoundary (boundaryEta , label ));
314+ }
315+ return boundaries ;
316+ }
317+
318+ private static class LayerBoundary {
319+ final double topEta ;
320+ final String label ;
321+
322+ LayerBoundary ( double topEta , String label ) {
323+ this .topEta = topEta ;
324+ this .label = label ;
325+ }
326+ }
327+
216328 /**
217329 * Forces the same reserved width and the same tick number format on every sub-plot's range
218330 * axis - see the class javadoc for why that's needed for the shared time axis to actually
@@ -257,6 +369,14 @@ private static String formatConstant( double value ) {
257369 return String .valueOf (value );
258370 }
259371
372+ /** Appends the BC type (e.g. "TOP_COUPLED") to the label if the output was written with
373+ * one (see {@code Whetgeo1DOutputsHandler.TABLE_OUTPUT_METADATA}); a plain fallback
374+ * label otherwise, so a value alone doesn't have to stand in for what kind of condition
375+ * produced it. */
376+ private static String bcLabel ( String base , String bcType ) {
377+ return bcType == null ? base : base + " (" + bcType + ")" ;
378+ }
379+
260380 private static double [] valueBounds ( double [] values ) {
261381 double lowerBound = Arrays .stream (values ).min ().orElse (0 );
262382 double upperBound = Arrays .stream (values ).max ().orElse (1 );
0 commit comments