(* A server-rendered SVG line chart with two data series on independent axes: weight on the left, reps on the right. No client script and no charting dependency -- the data is small and the shape is simple, so static SVG built from dream-html nodes fits the app's no-JS-first design and stays testable. SVG elements are built with [Dream_html.std_tag]; SVG in HTML5 needs no explicit namespace. Coordinates are plain floats in a fixed viewBox, so the chart scales to its container. *) open Dream_html let svg = std_tag "svg" let polyline = std_tag "polyline" let line = std_tag "line" let text = std_tag "text" let sattr = string_attr let fattr = float_attr (* Fixed drawing surface. The viewBox scales to the container width via CSS. A golden rectangle: the 640×396 surface is a 1.618:1 width:height ratio, which also keeps sparse two-point series from looking empty. The side gutters are fixed, so every chart's plot area starts at the same x and the stacked charts share one clean left edge. *) let width = 640. let height = 396. let pad_left = 52. let pad_right = 52. let pad_top = 30. let pad_bottom = 34. let plot_w = width -. pad_left -. pad_right let plot_h = height -. pad_top -. pad_bottom (* Map a value in [lo, hi] to a y pixel, inverted so larger is higher. A flat series (lo = hi) pins to the vertical middle rather than dividing by zero. *) let scale_y ~lo ~hi v = if Float.equal lo hi then pad_top +. (plot_h /. 2.) else pad_top +. (plot_h *. (1. -. ((v -. lo) /. (hi -. lo)))) (* Evenly spaced x for [n] points across the plot; a single point sits centre. *) let scale_x ~n i = if n <= 1 then pad_left +. (plot_w /. 2.) else pad_left +. (plot_w *. float_of_int i /. float_of_int (n - 1)) let bounds values = match values with | [] -> (0., 1.) | first :: _ -> let lo = List.fold_left Float.min first values in let hi = List.fold_left Float.max first values in (* Pad a flat or tiny range so the line is not glued to an edge. *) if Float.equal lo hi then (lo -. 1., hi +. 1.) else (lo, hi) let points_attr coords = coords |> List.map (fun (x, y) -> Printf.sprintf "%.1f,%.1f" x y) |> String.concat " " (* A plain stroked line: no markers, no area fill. *) let series ~css_class coords = polyline [ sattr "class" "%s" css_class; sattr "fill" "none"; sattr "points" "%s" (points_attr coords); ] [] let axis_line ~x1 ~y1 ~x2 ~y2 = line [ sattr "class" "chart-axis"; fattr "x1" x1; fattr "y1" y1; fattr "x2" x2; fattr "y2" y2; ] [] (* A faint full-width horizontal rule at fraction [f] of the plot height, to anchor the lines without competing with them. *) let gridline f = let y = pad_top +. (plot_h *. f) in line [ sattr "class" "chart-grid"; fattr "x1" pad_left; fattr "y1" y; fattr "x2" (pad_left +. plot_w); fattr "y2" y; ] [] let axis_label ~x ~y ~anchor ~css_class s = text [ sattr "class" "%s" css_class; fattr "x" x; fattr "y" y; sattr "text-anchor" "%s" anchor; ] [ txt "%s" s ] (* Render the chart for a series of (weight, reps) points in time order. The caller guarantees at least two points. *) let render (chart : View_model.Progress_page.chart) = let points = chart.points in let n = List.length points in let weights = List.map (fun (p : View_model.Progress_page.point) -> p.weight) points in let reps = List.map (fun (p : View_model.Progress_page.point) -> float_of_int p.reps) points in let w_lo, w_hi = bounds weights in let r_lo, r_hi = bounds reps in let weight_coords = List.mapi (fun i (p : View_model.Progress_page.point) -> (scale_x ~n i, scale_y ~lo:w_lo ~hi:w_hi p.weight)) points in let rep_coords = List.mapi (fun i (p : View_model.Progress_page.point) -> (scale_x ~n i, scale_y ~lo:r_lo ~hi:r_hi (float_of_int p.reps))) points in svg [ sattr "class" "progress-chart"; sattr "viewBox" "0 0 %g %g" width height; sattr "role" "img"; sattr "aria-label" "Line chart of weight and reps over time for %s" chart.exercise_name; sattr "preserveAspectRatio" "xMidYMid meet"; ] (let first_date = match points with p :: _ -> p.View_model.Progress_page.date | [] -> "" in let last_date = match List.rev points with | p :: _ -> p.View_model.Progress_page.date | [] -> "" in [ (* Faint gridlines first, so axes and series sit on top. *) gridline 0.25; gridline 0.5; gridline 0.75; (* Axes: left (weight), right (reps), and a baseline. *) axis_line ~x1:pad_left ~y1:pad_top ~x2:pad_left ~y2:(pad_top +. plot_h); axis_line ~x1:(pad_left +. plot_w) ~y1:pad_top ~x2:(pad_left +. plot_w) ~y2:(pad_top +. plot_h); axis_line ~x1:pad_left ~y1:(pad_top +. plot_h) ~x2:(pad_left +. plot_w) ~y2:(pad_top +. plot_h); (* Axis titles, anchored just above their axis, clear of the top edge. *) axis_label ~x:pad_left ~y:(pad_top -. 10.) ~anchor:"middle" ~css_class:"chart-axis-title weight" "kg"; axis_label ~x:(pad_left +. plot_w) ~y:(pad_top -. 10.) ~anchor:"middle" ~css_class:"chart-axis-title reps" "reps"; (* Left axis bounds (weight, kg). *) axis_label ~x:(pad_left -. 6.) ~y:(pad_top +. 4.) ~anchor:"end" ~css_class:"chart-tick weight" (Printf.sprintf "%g" w_hi); axis_label ~x:(pad_left -. 6.) ~y:(pad_top +. plot_h) ~anchor:"end" ~css_class:"chart-tick weight" (Printf.sprintf "%g" w_lo); (* Right axis bounds (reps). *) axis_label ~x:(pad_left +. plot_w +. 6.) ~y:(pad_top +. 4.) ~anchor:"start" ~css_class:"chart-tick reps" (Printf.sprintf "%g" r_hi); axis_label ~x:(pad_left +. plot_w +. 6.) ~y:(pad_top +. plot_h) ~anchor:"start" ~css_class:"chart-tick reps" (Printf.sprintf "%g" r_lo); (* X-axis: first and last dates. *) axis_label ~x:pad_left ~y:(pad_top +. plot_h +. 20.) ~anchor:"start" ~css_class:"chart-tick date" first_date; axis_label ~x:(pad_left +. plot_w) ~y:(pad_top +. plot_h +. 20.) ~anchor:"end" ~css_class:"chart-tick date" last_date; (* The two series. *) series ~css_class:"chart-series weight" weight_coords; series ~css_class:"chart-series reps" rep_coords; ])