PAD + React

PAD draws, React steers

React holds the controls; PAD renders the {chart} as SVG and answers just that {fragment}. The first chart is in the HTML before any script ran.

{chart}{fragment}padFragment dangerouslySetInnerHTML
Live
Sales per month, in thousandsJan: Online 42, Shop 31, Wholesale 18; Feb: Online 45, Shop 29, Wholesale 20; Mar: Online 51, Shop 34, Wholesale 22; Apr: Online 49, Shop 36, Wholesale 21; May: Online 58, Shop 38, Wholesale 25; Jun: Online 63, Shop 41, Wholesale 27; Jul: Online 61, Shop 39, Wholesale 30; Aug: Online 66, Shop 37, Wholesale 28; Sep: Online 72, Shop 42, Wholesale 31; Oct: Online 78, Shop 45, Wholesale 33; Nov: Online 91, Shop 52, Wholesale 36; Dec: Online 104, Shop 61, Wholesale 40OnlineShopWholesale0255075100125JanFebMarAprMayJunJulAugSepOctNovDecJan 路 Online: 42Jan 路 Shop: 31Jan 路 Wholesale: 18Feb 路 Online: 45Feb 路 Shop: 29Feb 路 Wholesale: 20Mar 路 Online: 51Mar 路 Shop: 34Mar 路 Wholesale: 22Apr 路 Online: 49Apr 路 Shop: 36Apr 路 Wholesale: 21May 路 Online: 58May 路 Shop: 38May 路 Wholesale: 25Jun 路 Online: 63Jun 路 Shop: 41Jun 路 Wholesale: 27Jul 路 Online: 61Jul 路 Shop: 39Jul 路 Wholesale: 30Aug 路 Online: 66Aug 路 Shop: 37Aug 路 Wholesale: 28Sep 路 Online: 72Sep 路 Shop: 42Sep 路 Wholesale: 31Oct 路 Online: 78Oct 路 Shop: 45Oct 路 Wholesale: 33Nov 路 Online: 91Nov 路 Shop: 52Nov 路 Wholesale: 36Dec 路 Online: 104Dec 路 Shop: 61Dec 路 Wholesale: 40

How it is built

PAD, on the server React, in the browser

<div data-island="ChartStudio" data-props="{^choice}">
{fragment 'chart'}
  <div class="chart-frame" data-total="{$total}">
    {if $stacked}
      {chart 'bar', data='sales', label='month', value=$value, stacked, title='Sales per month, in thousands'}
    {else}
      {chart $chartKind, data='sales', label='month', value=$value, title='Sales per month, in thousands'}
    {/if}
  </div>
{/fragment}
</div>

<script type="text/babel" src="{$padRoot}react/examples/chart.js"></script>
<?php

  // The chart the request asks for: its kind, how many months, which channels. The browser
  // gets the whole page; the component asks for &padFragment=chart with its choices and gets
  // the {fragment 'chart'} of chart.pad alone - the SVG PAD drew, nothing around it.

  $kind     = padRequest ( 'kind', 'bar' );
  $kind     = in_array ( $kind, [ 'bar', 'stacked', 'line', 'hbar' ] ) ? $kind : 'bar';
  $months   = max ( 3, min ( 12, (int) padRequest ( 'months', 12 ) ) );
  $channels = array_values ( array_intersect ( [ 'online', 'shop', 'wholesale' ], explode ( ',', reactText ( 'channels', 'online,shop,wholesale' ) ) ) );

  if ( ! $channels )
    $channels = [ 'online' ];

  $sales     = array_slice ( reactData ( 'sales' ), -$months );
  $value     = implode ( ', ', $channels );
  $stacked   = ( $kind == 'stacked' );
  $chartKind = $stacked ? 'bar' : $kind;
  $total     = array_sum ( array_map ( fn ( $row ) => array_sum ( array_intersect_key ( $row, array_flip ( $channels ) ) ), $sales ) );

  $choice = [ 'kind' => $kind, 'months' => $months, 'channels' => $channels ];

?>
// React holds the choices, PAD draws. The island arrived with the first chart already in it
// - PAD rendered the {fragment 'chart'} inside, so the chart was there before any script -
// and the component keeps that HTML as its starting picture. Each change asks the page for
// that one fragment again (&padFragment=chart) and puts the SVG it answers in place.

const chartKinds = [['bar', 'Bars'], ['stacked', 'Stacked'], ['line', 'Lines'], ['hbar', 'Sideways']];
const chartChannels = ['online', 'shop', 'wholesale'];

function ChartStudio({ kind: firstKind, months: firstMonths, channels: firstChannels, serverHtml }) {
  const [kind, setKind] = React.useState(firstKind);
  const [months, setMonths] = React.useState(firstMonths);
  const [channels, setChannels] = React.useState(firstChannels);
  const [html, setHtml] = React.useState(serverHtml);
  const [busy, setBusy] = React.useState(false);
  const first = React.useRef(true);

  // the choices as one text, so the debounce compares values - an object is new each render
  const asked = PadReact.useDebounced(JSON.stringify({ kind, months, channels: channels.join(',') }), 120);

  React.useEffect(() => {
    if (first.current) { first.current = false; return; }
    const controller = new AbortController();
    setBusy(true);
    PadReact.fragment('examples/chart', 'chart', JSON.parse(asked), { signal: controller.signal })
      .then(answer => { setHtml(answer); setBusy(false); })
      .catch(error => { if (error.name !== 'AbortError') setBusy(false); });
    return () => controller.abort();
  }, [asked]);

  const flip = channel => setChannels(now =>
    now.includes(channel) ? (now.length > 1 ? now.filter(one => one !== channel) : now)
                          : chartChannels.filter(one => one === channel || now.includes(one)));

  const total = (html.match(/data-total="(\d+)"/) || [])[1];

  return (
    <div>
      <div className="toolbar">
        <div className="pills" role="group" aria-label="Kind">
          {chartKinds.map(([key, label]) => (
            <button key={key} className={'pill' + (kind === key ? ' is-on' : '')} onClick={() => setKind(key)}>{label}</button>
          ))}
        </div>
        <div className="pills" role="group" aria-label="Channels" style={{ marginLeft: 'auto' }}>
          {chartChannels.map(channel => (
            <button key={channel} className={'pill' + (channels.includes(channel) ? ' is-on' : '')} aria-pressed={channels.includes(channel)}
                    onClick={() => flip(channel)}>{channel}</button>
          ))}
        </div>
      </div>

      <label className="small" style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
        <span style={{ whiteSpace: 'nowrap' }}>Last {months} months</span>
        <input type="range" min="3" max="12" value={months} onChange={event => setMonths(Number(event.target.value))} style={{ flex: 1 }} />
        {busy ? <span className="spinner" /> : <span className="badge is-pad">{total ? total + 'k in total' : ''}</span>}
      </label>

      <div className="chart-frame" style={{ opacity: busy ? .5 : 1, transition: 'opacity .2s' }} dangerouslySetInnerHTML={{ __html: html }} />

      <RequestLog title="Fragments from PAD" />
    </div>
  );
}

PadReact.island('ChartStudio', ChartStudio);