metrics = selectMetricsForActivity(activity, isIndoor);
+ int metricsBlockHeight = computeMetricsBlockHeight(metrics);
+ int availableHeight = metricsBottomLimit - titleEndY;
+ int metricsStartY = titleEndY + Math.max(0, (availableHeight - metricsBlockHeight) / 2);
+ drawMetrics(g2d, metrics, contentX, metricsStartY);
+ }
+
+ /**
+ * Draw the translucent-tint activity type badge. Pill shape with a 1px
+ * border, subtle background tint matching the activity type, and the
+ * type label in the brand color. Returns the Y position immediately
+ * below the rendered badge.
+ *
+ * Alpha values per spec: 12% fill / 25% border for cyan / green;
+ * 15% / 30% for pink (which is brighter so it can carry slightly more).
+ * Composition is forced to {@code AlphaComposite.SrcOver} so the
+ * translucency renders correctly even on a TYPE_INT_RGB target.
+ */
+ private int drawActivityTypeBadge(Graphics2D g2d, Activity activity, int x, int y, boolean isIndoor) {
+ Color tint;
+ Color border;
+ Color text;
+ if (isIndoor) {
+ tint = withAlpha(BRAND_ORANGE, 0.12f);
+ border = withAlpha(BRAND_ORANGE, 0.25f);
+ text = BRAND_ORANGE;
+ } else {
+ switch (activity.getActivityType()) {
+ case RIDE:
+ tint = withAlpha(BRAND_CYAN, 0.12f);
+ border = withAlpha(BRAND_CYAN, 0.25f);
+ text = BRAND_CYAN;
+ break;
+ case HIKE:
+ case WALK:
+ case MOUNTAINEERING:
+ tint = withAlpha(BRAND_GREEN, 0.12f);
+ border = withAlpha(BRAND_GREEN, 0.25f);
+ text = BRAND_GREEN;
+ break;
+ case RUN:
+ default:
+ tint = withAlpha(BRAND_PINK, 0.15f);
+ border = withAlpha(BRAND_PINK, 0.30f);
+ text = BRAND_PINK;
+ break;
}
}
- // Second pass: draw main track line (thinner, full opacity)
- g2d.setStroke(new BasicStroke(4.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+ Font badgeFont = new Font(HEAVY_FAMILY, Font.BOLD, 11);
+ g2d.setFont(badgeFont);
+ FontMetrics fm = g2d.getFontMetrics();
+ String label = ActivityFormatter.formatActivityType(activity.getActivityType()).toUpperCase();
+ int labelW = fm.stringWidth(label);
+ int padX = 10;
+ int padY = 5;
+ int badgeW = labelW + padX * 2;
+ int badgeH = fm.getAscent() + padY * 2;
- for (int i = 0; i < trackPoints.size() - 1; i++) {
- Map point1 = trackPoints.get(i);
- Map point2 = trackPoints.get(i + 1);
+ // Force standard source-over compositing so translucent fills blend
+ // correctly against the dark panel background. (Default on Graphics2D,
+ // but set explicitly here so it survives any prior state changes.)
+ Composite prevComposite = g2d.getComposite();
+ g2d.setComposite(AlphaComposite.SrcOver);
- Double lat1 = getDouble(point1, "latitude");
- Double lon1 = getDouble(point1, "longitude");
- Double lat2 = getDouble(point2, "latitude");
- Double lon2 = getDouble(point2, "longitude");
+ g2d.setColor(tint);
+ g2d.fillRoundRect(x, y, badgeW, badgeH, 10, 10);
+ g2d.setColor(border);
+ g2d.setStroke(new BasicStroke(1.0f));
+ g2d.drawRoundRect(x, y, badgeW, badgeH, 10, 10);
- if (lat1 != null && lon1 != null && lat2 != null && lon2 != null) {
- // Convert lat/lon to Web Mercator coordinates (same projection as OSM tiles)
- double mercatorX1 = longitudeToWebMercatorX(lon1);
- double mercatorY1 = latitudeToWebMercatorY(lat1);
- double mercatorX2 = longitudeToWebMercatorX(lon2);
- double mercatorY2 = latitudeToWebMercatorY(lat2);
+ g2d.setColor(text);
+ g2d.drawString(label, x + padX, y + padY + fm.getAscent());
- // Map Web Mercator coordinates to pixel coordinates within the letterbox
- double x1 = (mercatorX1 - minX) * pixelsPerMercatorX + letterbox.offsetX;
- double y1 = (mercatorY1 - minY) * pixelsPerMercatorY + letterbox.offsetY;
- double x2 = (mercatorX2 - minX) * pixelsPerMercatorX + letterbox.offsetX;
- double y2 = (mercatorY2 - minY) * pixelsPerMercatorY + letterbox.offsetY;
+ g2d.setComposite(prevComposite);
+ return y + badgeH;
+ }
- // Calculate opacity based on distance from start/end
- double distanceFromStart = cumulativeDistances[i];
- double distanceFromEnd = totalDistance - cumulativeDistances[i];
+ /**
+ * Build a Color with the given fractional alpha (0.0–1.0) on top of the
+ * RGB values of {@code base}. Avoids the noisy
+ * {@code new Color(c.getRed(), c.getGreen(), c.getBlue(), 31)} pattern
+ * and centralises the alpha-conversion math.
+ */
+ private static Color withAlpha(Color base, float alpha) {
+ int a = Math.max(0, Math.min(255, Math.round(alpha * 255f)));
+ return new Color(base.getRed(), base.getGreen(), base.getBlue(), a);
+ }
- // Calculate fade opacity (0.0 to 1.0)
- float opacity = 1.0f;
+ /**
+ * Draw the activity title in heavy 18px, word-wrapping to a maximum of
+ * 3 lines. Only the third line is truncated with an ellipsis if the
+ * input is still too long — short titles use a single line and the
+ * extra vertical room flows to the metric block. Returns the Y position
+ * immediately below the rendered title block.
+ */
+ private static final int TITLE_MAX_LINES = 3;
- // Hide first 100m completely, fade in from 100m to 200m
- if (distanceFromStart < HIDDEN_DISTANCE) {
- opacity = 0.0f;
- } else if (distanceFromStart < FADE_DISTANCE) {
- // Fade in from 100m to 200m
- opacity = Math.min(opacity, (float) ((distanceFromStart - HIDDEN_DISTANCE) / (FADE_DISTANCE - HIDDEN_DISTANCE)));
+ private int drawActivityTitle(Graphics2D g2d, Activity activity, int x, int y, int maxWidth) {
+ String title = activity.getTitle();
+ if (title == null || title.isBlank()) title = "Activity";
+
+ Font titleFont = new Font(HEAVY_FAMILY, Font.BOLD, 18);
+ g2d.setFont(titleFont);
+ g2d.setColor(TEXT_PRIMARY);
+ FontMetrics fm = g2d.getFontMetrics();
+ // Tight line-height (1.15× ascent) so 3 lines don't dominate the panel.
+ int lineHeight = (int) Math.round(fm.getAscent() * 1.15);
+
+ // Heavy tier: render uppercase per the design system.
+ title = title.toUpperCase();
+
+ List lines = wrapToLines(title, fm, maxWidth, TITLE_MAX_LINES);
+ int cursorY = y + fm.getAscent();
+ for (String line : lines) {
+ g2d.drawString(line, x, cursorY);
+ cursorY += lineHeight;
+ }
+ return y + lines.size() * lineHeight;
+ }
+
+ /**
+ * Greedy word-wrap to a maximum of {@code maxLines}. The last line is
+ * truncated with an ellipsis if the input doesn't fit.
+ */
+ private static List wrapToLines(String text, FontMetrics fm, int maxWidth, int maxLines) {
+ List result = new ArrayList<>();
+ String[] words = text.split("\\s+");
+ StringBuilder current = new StringBuilder();
+ for (String word : words) {
+ String candidate = current.length() == 0 ? word : current + " " + word;
+ if (fm.stringWidth(candidate) <= maxWidth) {
+ current.setLength(0);
+ current.append(candidate);
+ } else {
+ if (current.length() > 0) {
+ result.add(current.toString());
+ if (result.size() == maxLines) {
+ // Out of lines — truncate the last one with the
+ // remaining input as suffix.
+ StringBuilder remaining = new StringBuilder(word);
+ // (we don't need to walk further; we just need an
+ // ellipsis on the last line)
+ return ellipsizeLast(result, remaining.toString(), fm, maxWidth);
+ }
+ current.setLength(0);
+ current.append(word);
+ } else {
+ // Single word longer than maxWidth — add it as-is and
+ // ellipsize on the next iteration.
+ result.add(word);
+ if (result.size() == maxLines) {
+ return ellipsizeLast(result, "", fm, maxWidth);
+ }
}
+ }
+ }
+ if (current.length() > 0) result.add(current.toString());
+ return result;
+ }
- // Hide last 100m completely, fade out from 200m to 100m before end
- if (distanceFromEnd < HIDDEN_DISTANCE) {
- opacity = 0.0f;
- } else if (distanceFromEnd < FADE_DISTANCE) {
- // Fade out from 200m to 100m before end
- opacity = Math.min(opacity, (float) ((distanceFromEnd - HIDDEN_DISTANCE) / (FADE_DISTANCE - HIDDEN_DISTANCE)));
+ private static List ellipsizeLast(List lines, String overflowSuffix, FontMetrics fm, int maxWidth) {
+ if (lines.isEmpty()) return lines;
+ String last = lines.get(lines.size() - 1);
+ String ellipsis = "…";
+ // Strip characters from the end of the last line until it + ellipsis fits.
+ while (!last.isEmpty() && fm.stringWidth(last + ellipsis) > maxWidth) {
+ last = last.substring(0, last.length() - 1);
+ }
+ lines.set(lines.size() - 1, last + ellipsis);
+ return lines;
+ }
+
+ /**
+ * Choose the four metrics shown in the panel based on activity type.
+ * Distance is always first (and is the hero metric — pink). Pace is
+ * shown for runs/walks/hikes; rides show average speed instead.
+ * Indoor activities replace elevation with average heart rate.
+ */
+ private List selectMetricsForActivity(Activity activity, boolean isIndoor) {
+ List metrics = new ArrayList<>();
+
+ // 1. Distance — hero
+ if (activity.getTotalDistance() != null) {
+ double km = activity.getTotalDistance().doubleValue() / 1000.0;
+ metrics.add(new MetricEntry(formatTwoDecimals(km), "km", "distance", true));
+ } else {
+ metrics.add(new MetricEntry("—", "", "distance", true));
+ }
+
+ // 2. Moving time / total time
+ Long movingTime = activity.getMetrics() != null ? activity.getMetrics().getMovingTimeSeconds() : null;
+ Long totalTime = activity.getTotalDurationSeconds();
+ Long timeToShow = movingTime != null && totalTime != null && movingTime < totalTime ? movingTime : totalTime;
+ if (timeToShow != null) {
+ metrics.add(new MetricEntry(formatDuration(timeToShow), "", "moving time", false));
+ }
+
+ // 3. Pace OR avg speed (sport-dependent)
+ Activity.ActivityType type = activity.getActivityType();
+ boolean isRideLike = type == Activity.ActivityType.RIDE || type == Activity.ActivityType.INLINE_SKATING;
+ if (isRideLike) {
+ BigDecimalAvgSpeed avg = readAverageSpeed(activity);
+ if (avg.kmh > 0) {
+ metrics.add(new MetricEntry(String.format("%.1f", avg.kmh), "km/h", "avg speed", false));
+ }
+ } else {
+ // Pace from total distance / total time. Falls back to N/A if either is missing.
+ if (activity.getTotalDistance() != null && timeToShow != null) {
+ double km = activity.getTotalDistance().doubleValue() / 1000.0;
+ if (km > 0) {
+ double paceMin = timeToShow / 60.0 / km;
+ metrics.add(new MetricEntry(formatPace(paceMin), "/km", "pace", false));
}
+ }
+ }
- // Skip completely transparent segments
- if (opacity <= 0.0f) {
- continue;
- }
+ // 4. Elevation OR (for indoor) heart rate
+ if (isIndoor) {
+ Integer hr = activity.getMetrics() != null ? activity.getMetrics().getAverageHeartRate() : null;
+ if (hr != null) {
+ metrics.add(new MetricEntry(String.valueOf(hr), "bpm", "avg heart rate", false));
+ }
+ } else {
+ if (activity.getElevationGain() != null) {
+ metrics.add(new MetricEntry(String.format("%.0f", activity.getElevationGain().doubleValue()), "m", "elevation", false));
+ }
+ }
- // Apply opacity to track color - neon cyan for 80s style
- int alpha = Math.max(0, Math.min(255, (int) (opacity * 255)));
- g2d.setColor(new Color(0, 255, 255, alpha)); // Neon cyan with alpha
+ // Cap at 4 entries
+ while (metrics.size() > 4) metrics.remove(metrics.size() - 1);
+ return metrics;
+ }
- // Draw segment
- g2d.drawLine((int) x1, (int) y1, (int) x2, (int) y2);
+ /** Read average speed in km/h from the metrics row. Returns 0 if absent. */
+ private BigDecimalAvgSpeed readAverageSpeed(Activity activity) {
+ BigDecimalAvgSpeed s = new BigDecimalAvgSpeed();
+ if (activity.getMetrics() == null) return s;
+ // ActivityMetrics.averageSpeed is already in km/h (parser converts from m/s).
+ if (activity.getMetrics().getAverageSpeed() != null) {
+ s.kmh = activity.getMetrics().getAverageSpeed().doubleValue();
+ }
+ return s;
+ }
+
+ /** Tiny mutable container so the metric-selection helper stays one-line. */
+ private static class BigDecimalAvgSpeed {
+ double kmh = 0;
+ }
+
+ // ── Metric layout constants ─────────────────────────────────────────
+ // The metric block is sized exactly so the caller can vertically center
+ // it in the available space. These constants are the source of truth
+ // shared by computeMetricsBlockHeight() and drawMetrics().
+ private static final int METRIC_VALUE_FONT_SIZE = 28;
+ private static final int METRIC_UNIT_FONT_SIZE = 13;
+ private static final int METRIC_LABEL_FONT_SIZE = 11;
+ /** Vertical distance from the value baseline to the top of the label text. */
+ private static final int METRIC_VALUE_TO_LABEL_GAP = 4;
+ /** Gap between consecutive metric entries (per spec). */
+ private static final int METRIC_INTER_GAP = 16;
+
+ /**
+ * Compute the exact pixel height of the metrics block for {@code n}
+ * entries with the constants above. Used by the panel layout to
+ * vertically center the block in the available space.
+ */
+ private int computeMetricsBlockHeight(List metrics) {
+ if (metrics == null || metrics.isEmpty()) return 0;
+ // One entry = ascent of value font (28px ≈ 22 ascent) + label gap +
+ // ascent of label font (11px ≈ 9 ascent). Approximate via the font
+ // sizes since FontMetrics aren't available without a Graphics context.
+ int valueAscent = (int) Math.round(METRIC_VALUE_FONT_SIZE * 0.78);
+ int labelAscent = (int) Math.round(METRIC_LABEL_FONT_SIZE * 0.82);
+ int entryHeight = valueAscent + METRIC_VALUE_TO_LABEL_GAP + labelAscent;
+ return entryHeight * metrics.size() + METRIC_INTER_GAP * (metrics.size() - 1);
+ }
+
+ /**
+ * Render the metric block as a tight vertical group, with exactly
+ * {@link #METRIC_INTER_GAP} pixels between entries. The block does NOT
+ * stretch to fill its container — the caller is responsible for choosing
+ * the {@code top} Y so the block appears centered (or wherever it should
+ * sit) in the available space.
+ */
+ private void drawMetrics(Graphics2D g2d, List metrics, int x, int top) {
+ if (metrics.isEmpty()) return;
+
+ Font valueFont = new Font(HEAVY_FAMILY, Font.BOLD, METRIC_VALUE_FONT_SIZE);
+ Font unitFont = new Font(REG_FAMILY, Font.PLAIN, METRIC_UNIT_FONT_SIZE);
+ Font labelFont = new Font(REG_FAMILY, Font.PLAIN, METRIC_LABEL_FONT_SIZE);
+
+ int cursorY = top;
+ for (int i = 0; i < metrics.size(); i++) {
+ MetricEntry m = metrics.get(i);
+
+ // Value (heavy 28px) — pink for the hero, white for the rest.
+ g2d.setFont(valueFont);
+ g2d.setColor(m.hero ? BRAND_PINK : TEXT_PRIMARY);
+ FontMetrics vfm = g2d.getFontMetrics();
+ int valueY = cursorY + vfm.getAscent();
+ g2d.drawString(m.value, x, valueY);
+
+ // Inline unit suffix (regular 13px, muted) — drawn on the same
+ // baseline as the value so "3.01 km" reads as a single line.
+ if (m.unit != null && !m.unit.isEmpty()) {
+ int valueW = vfm.stringWidth(m.value);
+ g2d.setFont(unitFont);
+ g2d.setColor(TEXT_MUTED);
+ g2d.drawString(" " + m.unit, x + valueW, valueY);
+ }
+
+ // Label (regular 11px, muted) directly below the value.
+ g2d.setFont(labelFont);
+ g2d.setColor(TEXT_MUTED);
+ FontMetrics lfm = g2d.getFontMetrics();
+ int labelY = valueY + METRIC_VALUE_TO_LABEL_GAP + lfm.getAscent();
+ g2d.drawString(m.label, x, labelY);
+
+ // Advance cursor to the next entry's top Y. The +4 below the
+ // label baseline approximates the descent of the 11px font.
+ cursorY = labelY + 4;
+ if (i < metrics.size() - 1) {
+ cursorY += METRIC_INTER_GAP;
}
}
}
/**
- * Draw the track from simplified track (LineString).
+ * Footer at the bottom of the panel: 1px separator with 12px breathing
+ * room above and below, then the FitPub brand in gradient text, then
+ * the user's Fediverse handle. The bottom of the handle text sits
+ * exactly {@link #PANEL_PAD_Y} pixels above the panel's bottom edge.
+ *
+ * Returns the total inner height of the footer (separator → bottom of
+ * handle text), which the caller uses to determine where the metric
+ * block's available space ends.
*/
- private void drawSimplifiedTrack(Graphics2D g2d, Activity activity, int width, int height) {
- // Similar logic but using simplified track coordinates
- // This is a fallback if high-res track points aren't available
- log.debug("Using simplified track for activity {}", activity.getId());
- // TODO: Implement if needed
+ private int drawPanelFooter(Graphics2D g2d, Activity activity, int contentX, int contentW) {
+ // Build everything from the bottom up so the bottom padding is exact.
+ Font brandFont = new Font(HEAVY_FAMILY, Font.BOLD, 13);
+ Font handleFont = new Font(REG_FAMILY, Font.PLAIN, 11);
+
+ FontMetrics bfm = g2d.getFontMetrics(brandFont);
+ FontMetrics hfm = g2d.getFontMetrics(handleFont);
+
+ String handle = buildFediverseHandle(activity);
+ boolean hasHandle = handle != null;
+
+ // Vertical layout (anchored to the bottom):
+ // ... separator
+ // 12px gap
+ // brand (ascent of brandFont)
+ // 2px gap
+ // handle (ascent of handleFont) ← only if hasHandle
+ // 20px bottom padding
+ int handleAscent = hasHandle ? hfm.getAscent() : 0;
+ int brandToHandleGap = hasHandle ? 2 : 0;
+ int brandAscent = bfm.getAscent();
+ int separatorToBrandGap = 12;
+
+ // Y of the handle baseline (= bottom of the handle text).
+ int handleBaselineY = IMAGE_HEIGHT - PANEL_PAD_Y;
+ // Y of the brand baseline.
+ int brandBaselineY = handleBaselineY - handleAscent - brandToHandleGap;
+ // Y of the separator line (1px tall).
+ int separatorY = brandBaselineY - brandAscent - separatorToBrandGap;
+
+ // 1px top separator across the panel content width.
+ g2d.setColor(PANEL_BORDER);
+ g2d.fillRect(contentX, separatorY, contentW, 1);
+
+ // FitPub brand — gradient pink → cyan via GradientPaint, drawn
+ // through the existing string renderer (not character-by-character)
+ // so the gradient covers the whole word as a single shape.
+ g2d.setFont(brandFont);
+ String brand = "FITPUB";
+ int brandWidth = bfm.stringWidth(brand);
+ GradientPaint brandGradient = new GradientPaint(
+ contentX, brandBaselineY - brandAscent, BRAND_PINK,
+ contentX + (float) brandWidth, brandBaselineY, BRAND_CYAN
+ );
+ Paint prevPaint = g2d.getPaint();
+ g2d.setPaint(brandGradient);
+ g2d.drawString(brand, contentX, brandBaselineY);
+ g2d.setPaint(prevPaint);
+
+ // Fediverse handle
+ if (hasHandle) {
+ g2d.setFont(handleFont);
+ g2d.setColor(HANDLE_MUTED);
+ g2d.drawString(handle, contentX, handleBaselineY);
+ }
+
+ // Inner height = from the separator's top to the handle's baseline.
+ return (handleBaselineY - separatorY);
+ }
+
+ /**
+ * Build a Fediverse-style handle (@username@host) from the activity's
+ * owner. Returns null if the user can't be looked up — the footer just
+ * omits the handle row in that case.
+ */
+ private String buildFediverseHandle(Activity activity) {
+ try {
+ User user = userRepository.findById(activity.getUserId()).orElse(null);
+ if (user == null || user.getUsername() == null) return null;
+ String host = baseUrlHost();
+ if (host == null) return "@" + user.getUsername();
+ return "@" + user.getUsername() + "@" + host;
+ } catch (Exception e) {
+ log.debug("Could not resolve Fediverse handle for activity {}: {}", activity.getId(), e.getMessage());
+ return null;
+ }
+ }
+
+ private String baseUrlHost() {
+ try {
+ return URI.create(baseUrl).getHost();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Formatting helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static String formatTwoDecimals(double v) {
+ return String.format("%.2f", v);
+ }
+
+ private static String formatDuration(long seconds) {
+ long h = seconds / 3600;
+ long m = (seconds % 3600) / 60;
+ long s = seconds % 60;
+ if (h > 0) return String.format("%d:%02d:%02d", h, m, s);
+ return String.format("%d:%02d", m, s);
+ }
+
+ /** Format pace minutes (decimal) as M:SS. */
+ private static String formatPace(double minutes) {
+ if (!Double.isFinite(minutes) || minutes <= 0) return "—";
+ int totalSeconds = (int) Math.round(minutes * 60);
+ int m = totalSeconds / 60;
+ int s = totalSeconds % 60;
+ return String.format("%d:%02d", m, s);
+ }
+
+ /** Tiny container for a metric to render. */
+ private static class MetricEntry {
+ final String value;
+ final String unit;
+ final String label;
+ final boolean hero;
+ MetricEntry(String value, String unit, String label, boolean hero) {
+ this.value = value;
+ this.unit = unit;
+ this.label = label;
+ this.hero = hero;
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Existing helpers preserved from the previous implementation
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Helper to safely extract Double from Map.
+ */
+ private Double getDouble(Map map, String key) {
+ Object value = map.get(key);
+ if (value instanceof Number) {
+ return ((Number) value).doubleValue();
+ }
+ return null;
+ }
+
+ /**
+ * Get the file path for an activity image.
+ */
+ public File getActivityImageFile(UUID activityId) {
+ return new File(imagesPath, activityId + ".png");
+ }
+
+ /**
+ * Parses track points from JSONB string. Only fields needed for the
+ * map rendering (lat / lon / elevation) are pulled out.
+ */
+ private List