Skip to content
CrossFly is 100% open sourceFollow us on GitHub
CrossFlyCrossFly
Get started
All examples
AppsCharts495 lines · 2 files

Pulse — a dashboard

One screen, read rather than operated: a headline number, a KPI grid and charts, all re-fed by the single control that changes the period.

What this shows

Pulse — the dashboard shape: one screen, read rather than operated.

gradlew lwjgl3:run --args="classname dev.cf.app.dashboard.DashboardApp"

What makes a dashboard different from a listing

A listing is scanned for one row. A dashboard is read in a fixed order, and the order is the design: headline, then supporting figures, then the shape over time, then the breakdown. A reader who has to hunt for the number the screen exists to show is looking at a chart gallery, not a dashboard.

Three decisions carry most of the weight here.

1 · One period, one snapshot, one rebuild

The period chips are the only control. Changing one takes a fresh Metrics and rebuilds the body from it. Patching the pieces individually is faster and is how a dashboard ends up with a KPI from March next to a chart from April — the failure nobody notices in review and everybody notices in a meeting.

2 · Semantic colour is separate from the accent

Up and down are read at a glance and must not be the brand colour, or "on brand" and "doing well" become the same signal. They come from theme tokens all the same (Styles.primaryColor, secondary()), so the whole screen still follows the skin and dark mode.

3 · A number states its own units and its own comparison

"12,480" is not information. "12,480, up 12.4% on last week" is. Every KPI here carries a caption saying what it counts, and the headline carries its delta.

Run it on your machine

Every example is a plain Window, so the launcher only needs its name.

gradlew lwjgl3:run --args="classname dev.cf.app.dashboard.DashboardApp"
Editing it

Java is compiled ahead of time, so the browser cannot rebuild an edit on its own — the code you see here was turned into JavaScript by TeaVM before it was published. Copy the file into a project and it runs unchanged on all four platforms; the command above is the whole setup.

Running hereCrossFly 0.1.32
Starting the app…

Compiled to WebAssembly-era JavaScript by TeaVM — the same code, not a video of it. · All examples

What it uses

Framework types this example touches. The linked ones have a reference page.

CardCardGridChartViewChipColorUtilsComboChartComponentDebugDonutChartGapHorizontalLayoutLabelPalColorRadialProgressRelativeScreenStylesVerticalLayoutWidgetsWindow

The source

Complete and unabridged. This is the file, not an excerpt of it.

Raw
dev/cf/app/dashboard/DashboardApp.java
package dev.cf.app.dashboard;

import dev.cf.Described;
import dev.cf.Tones;
import dev.cf.app.AppBar;
import dev.cf.app.AppTheme;

import dev.crossfly.ui.*;
import dev.crossfly.ui.chart.*;
import dev.crossfly.ui.color.*;
import dev.crossfly.ui.color.ColorUtils;
import dev.crossfly.ui.graphics.*;
import dev.crossfly.ui.layout.*;
import dev.crossfly.ui.style.*;

import static dev.crossfly.ui.Widgets.*;

/**
 * <b>Pulse</b> — the dashboard shape: one screen, read rather than operated.
 *
 * <pre>
 * gradlew lwjgl3:run --args="classname dev.cf.app.dashboard.DashboardApp"
 * </pre>
 *
 * <h2>What makes a dashboard different from a listing</h2>
 *
 * <p>A listing is scanned for one row. A dashboard is read in a fixed order, and the order is the
 * design: <b>headline, then supporting figures, then the shape over time, then the breakdown</b>.
 * A reader who has to hunt for the number the screen exists to show is looking at a chart gallery,
 * not a dashboard.</p>
 *
 * <p>Three decisions carry most of the weight here.</p>
 *
 * <h3>1 · One period, one snapshot, one rebuild</h3>
 *
 * <p>The period chips are the only control. Changing one takes a fresh {@link Metrics} and rebuilds
 * the body from it. Patching the pieces individually is faster and is how a dashboard ends up with
 * a KPI from March next to a chart from April — the failure nobody notices in review and everybody
 * notices in a meeting.</p>
 *
 * <h3>2 · Semantic colour is separate from the accent</h3>
 *
 * <p>Up and down are read at a glance and must not be the brand colour, or "on brand" and "doing
 * well" become the same signal. They come from theme tokens all the same
 * ({@code Styles.primaryColor}, {@code secondary()}), so the whole screen still follows the skin
 * and dark mode.</p>
 *
 * <h3>3 · A number states its own units and its own comparison</h3>
 *
 * <p>"12,480" is not information. "12,480, up 12.4% on last week" is. Every KPI here carries a
 * caption saying what it counts, and the headline carries its delta.</p>
 */
public class DashboardApp extends Window implements Described
{
   private Metrics.Period period = Metrics.Period.WEEK;
   private Metrics data;

   private Component body;
   private Chip[] chips;

   @Override
   public String title()
   {
      return "Pulse — a dashboard";
   }

   @Override
   public String note()
   {
      return "One screen, read rather than operated: a headline number, a KPI grid and charts,"
         + " all re-fed by the single control that changes the period.";
   }

   /** Hands the palette back to whatever was in force before this app opened. */
   @Override
   public void onRemove()
   {
      super.onRemove();
      AppTheme.leave();
   }

   @Override
   public void onAdd(boolean firstTime) throws Exception
   {
      if (!firstTime)
         return;
      // The brand FIRST: fullscreenColor below reads a token, and reading it before the palette
      // is declared bakes in the skin's neutral grey for the life of the window.
      AppTheme.enter();
      // The PAGE, not the surface. Cards, buttons and fields are all drawn on the surface, so a
      // page painted the same colour makes every one of their edges disappear. Read through
      // onRestyle so a light/dark flip re-derives it instead of keeping the old mode's value.
      fullscreenColor = AppTheme.pageColor();
      onRestyle(() -> fullscreenColor = AppTheme.pageColor());
      setLayout(new VerticalLayout(Position.START, Position.START));

      add(0f, new AppBar("Pulse", isRoot() ? null : this::hide));
      add(0f, periodRow());

      body = new Component();
      body.setLayout(new VerticalLayout(Position.START, Position.START));
      body.setScrollable(ScrollDirection.VERTICAL);
      body.getStyle()
         .ingap(Gap.ofFont(1f, .5f, 1f, 1f));
      body.testTag("dash.body");
      add(LAY_FILL, body);

      rebuild();
   }

   /** The one control. Chips rather than a dropdown: three options a thumb can reach. */
   private Component periodRow() throws Exception
   {
      Component row = new Component();
      row.setLayout(new HorizontalLayout(Position.START, Position.CENTER));
      row.getStyle()
         .ingap(Gap.ofFont(1f, .5f, 1f, .5f));

      Metrics.Period[] all = Metrics.Period.values();
      chips = new Chip[all.length];
      for (int i = 0; i < all.length; i++)
      {
         final Metrics.Period p = all[i];
         Chip c = new Chip(p.label);
         c.testTag("dash.period." + p.name()
            .toLowerCase());
         c.setSelected(p == period);
         c.onAction(e ->
         {
            period = p;
            for (Chip other : chips)
               other.setSelected(false);
            c.setSelected(true);
            rebuild();
            Screen.repaint();
         });
         c.getStyle()
            .outgap(Gap.ofFont(0, 0, .4f, 0));
         chips[i] = c;
         row.add(LAY_AUTO, c);
      }
      return row;
   }

   /** Takes a snapshot and builds the whole body from it. Never partially. */
   private void rebuild()
   {
      try
      {
         data = Metrics.of(period);
         body.removeAll();
         // removeAll() drops the layout too — put it back before adding anything.
         body.setLayout(new VerticalLayout(Position.START, Position.START));

         body.add(LAY_AUTO, outgap(hero(), 0, 0, 0, 1f));
         body.add(LAY_AUTO, outgap(kpis(), 0, 0, 0, 1.2f));
         body.add(LAY_AUTO, outgap(sectionTitle("Revenue over time"), 0, 0, 0, .5f));
         body.add(LAY_AUTO, outgap(trend(), 0, 0, 0, 1.2f));
         body.add(LAY_AUTO, outgap(sectionTitle("Where it came from"), 0, 0, 0, .5f));
         body.add(LAY_AUTO, channels());

         body.setValidLayout(false);
      }
      catch (Exception e)
      {
         dev.crossfly.util.Debug.handle(e);
      }
   }

   /**
    * The hero: the one number the screen exists to show, its delta, and nothing else.
    *
    * <p>Resisting the fourth item here is most of the work. A hero with four figures in it has no
    * hero, and the reader has to decide which one matters — which is the decision the dashboard was
    * supposed to have made for them.</p>
    */
   private Component hero() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));
      c.testTag("dash.hero");

      c.add(LAY_AUTO, label("REVENUE · " + period.label.toUpperCase(), 1f, true,
         Styles.captionColor, Position.START));

      Label big = label(data.revenue(), 3f, true, Styles.textColor, Position.START);
      big.testTag("dash.revenue");
      c.add(LAY_AUTO, outgap(big, 0, .2f, 0, .3f));

      Component deltaRow = new Component();
      deltaRow.setLayout(new HorizontalLayout(Position.START, Position.CENTER));
      PalColor tone = data.deltaIsGood() ? Styles.primaryColor : secondary();
      deltaRow.add(LAY_AUTO, matglyph(data.deltaIsGood() ? 0xE5D8 : 0xE5DB, tone, 1.2f));
      Label d = label(data.delta() + " on the previous period", 1f, true, tone, Position.START);
      d.testTag("dash.delta");
      deltaRow.add(LAY_AUTO, outgap(d, .3f, 0, 0, 0));
      c.add(LAY_AUTO, deltaRow);

      return c;
   }

   /**
    * The supporting figures.
    *
    * <p>A {@link CardGrid} rather than a fixed row: on a phone these stack, on a tablet they sit
    * side by side, and neither case needed a width check or an orientation listener. Reflow is the
    * framework's job.</p>
    */
   private Component kpis() throws Exception
   {
      CardGrid g = new CardGrid().columns(2, 4)
         .cardWidth(11f)
         .gap(.6f);
      g.testTag("dash.kpis");
      g.card(kpi("Orders", "" + data.orders, "closed in the period", 0xE8CC));
      g.card(kpi("Customers", "" + data.customers, "who bought at least once", 0xE7EF));
      g.card(kpi("Average order", Metrics.money(data.orders == 0 ? 0
         : data.revenueCents / data.orders), "revenue ÷ orders", 0xE263));
      g.card(gauge());
      return g;
   }

   private Component kpi(String title, String value, String caption, int glyph) throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));
      c.testTag("dash.kpi." + title.toLowerCase()
         .replace(' ', '.'));
      c.add(LAY_AUTO, matglyph(glyph, Styles.primaryColor, 1.4f));
      c.add(LAY_AUTO, outgap(label(value, 2f, true, Styles.textColor, Position.START), 0, .3f, 0,
         .1f));
      c.add(LAY_AUTO, label(title, 1f, true, Styles.textColor, Position.START));
      // The caption is what turns a number into a fact. It is the first thing cut for space and the
      // first thing missed in a meeting.
      c.add(LAY_AUTO, label(caption, 1f, false, Styles.captionColor, Position.START));
      return c;
   }

   /** The one KPI that is a ratio, so it gets the shape a ratio deserves. */
   private Component gauge() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.CENTER, Position.CENTER));
      c.testTag("dash.kpi.conversion");
      RadialProgress r = new RadialProgress().progress(data.conversion)
         .text(data.conversionText())
         .caption("converted");
      c.add(LAY_AUTO, r);
      c.add(LAY_AUTO, outgap(label("Conversion", 1f, true, Styles.textColor, Position.CENTER), 0,
         .4f, 0, 0));
      return c;
   }

   /**
    * Revenue and orders on one chart.
    *
    * <p>Two series that share a period belong on one chart; two charts side by side make the reader
    * do the alignment themselves and they will do it wrong. The categories come from the same
    * snapshot as the values, which is what keeps the axis honest when the period changes.</p>
    */
   private Component trend() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));

      ComboChart chart = new ComboChart();
      chart.categories(data.labels);
      // The colour is passed explicitly. autoThemeColors below themes the axes, the grid and the
      // plot background, but NOT the series: a series left at colour 0 falls back to
      // ChartView.DEFAULT_COLORS, whose first entry is a dark grey — so the largest thing on the
      // screen ends up being the one thing that ignores the brand.
      chart.addSeries(new ChartView.Series("Revenue", data.revenueSeries,
         Styles.primaryColor.color()).bar());
      // The second series is the SAME hue, shaded towards the text colour rather than a different
      // colour entirely: the two are measuring the same thing over the same period, so they should
      // look related. Shading towards the text and not towards black is what keeps it working in
      // dark mode, where "darker" is the wrong direction.
      // A ROLE rather than the brand dimmed towards the text colour: two measures on one chart
      // have to be told apart without consulting the legend, and the dimmed brand read as grey.
      chart.addSeries(new ChartView.Series("Orders", data.ordersSeries, Tones.highlight().color())
            .line());
      chart.showLegend = true;
      chart.autoThemeColors(true);
      chart.getStyle()
         .autoHeight(Relative.ofFont(14f));
      chart.testTag("dash.trend");
      c.add(LAY_AUTO, chart);
      return c;
   }

   /** The breakdown. A donut, because the question is "what share", not "how much". */
   private Component channels() throws Exception
   {
      Card c = new Card();
      c.setLayout(new VerticalLayout(Position.START, Position.START));

      DonutChart d = new DonutChart();
      // One hue, four steps, darkest first — a share-of-total chart is asking "how much of it",
      // and four unrelated colours make the reader learn a legend to answer that. The ramp is
      // derived from the brand, so it follows a change of BRAND and it follows dark mode.
      int base = Styles.primaryColor.color(), ground = Styles.backColor.color();
      for (int i = 0; i < Metrics.CHANNEL.length; i++)
         d.slice(Metrics.CHANNEL[i], data.channelValues[i],
            ColorUtils.lerpColor(base, ground, i * .21f));
      d.hole(.62f);
      d.autoThemeColors(true);
      d.getStyle()
         .autoHeight(Relative.ofFont(14f));
      d.testTag("dash.channels");
      c.add(LAY_AUTO, d);
      return c;
   }

   private Component sectionTitle(String t) throws Exception
   {
      return label(t, 1.2f, true, Styles.textColor, Position.START);
   }
}
For an AI assistant

Machine-readable versions of this page, so a model can read the shelf instead of guessing at the API.

This example, as JSONThis example, as Markdown
PreviousDepot — offline-first CRUDNextFieldwork — capturing from the device