Skip to content
Merged
167 changes: 167 additions & 0 deletions src/lib/components/home/HowItWorks.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<!--
HowItWorks — the three step guide on the home page.

The user can hide the guide. The choice persists, so a return visit keeps the
page short.
-->

<script lang="ts">
import { onMount } from 'svelte';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import Card from '@/components/card/card.svelte';
import Button from '@/components/buttons/Button.svelte';

const STORAGE_KEY = 'beacon-studio.home.guide-open';

type Step = {
/** The step number, as the card shows it. */
number: string;
title: string;
body: string;
};

const STEPS: Step[] = [
{
number: '01',
title: 'Select node and table',
body: 'Open the Query Workbench. Pick a connected node, then pick one data table on that node.'
},
{
number: '02',
title: 'Filter and query',
body: 'Add the columns you need, then filter them. Set a depth range and time bounds. To limit the result to a region, draw the area with the Map Viewer tools.'
},
{
number: '03',
title: 'Explore, export and share',
body: 'Download the result or view it in the Map Viewer. Check the values in the Table Explorer, or build a chart. Share the query to collaborate or to continue in another workspace.'
}
];

let open = $state(true);

// Read the choice after the mount. A read at the module load reaches no
// storage on a prerender.
onMount(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved !== null) open = saved === 'true';
});

function toggle(): void {
open = !open;
localStorage.setItem(STORAGE_KEY, String(open));
}
</script>

<div class="section-head">
<h2>How it works</h2>
<p class="lead">Three steps from an empty query to a downloaded file</p>
<Button variant="link" onclick={toggle}>{open ? 'Hide guide' : 'Show guide'}</Button>
</div>

{#if open}
<div class="steps">
{#each STEPS as step, index (step.number)}
<Card class="step">
<div class="step-head">
<span class="step-number">{step.number}</span>
<h3>{step.title}</h3>
</div>
<p>{step.body}</p>
</Card>

{#if index < STEPS.length - 1}
<div class="step-arrow" aria-hidden="true">
<ArrowRightIcon />
</div>
{/if}
{/each}
</div>
{/if}

<style lang="scss">
.section-head {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 1rem;

h2 {
margin: 0;
}

.lead {
margin: 0;
flex: 1;
color: var(--muted-foreground);
}
}

// The arrow sits in a track of its own, so it never takes the room that a
// card needs. `auto-fit` cannot do that, because the arrow is a grid item.
.steps {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
align-items: stretch;
gap: 0.5rem;

:global(.card.step) {
height: 100%;
}

.step-head {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;

h3 {
margin: 0;
}
}

.step-number {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 1.75rem;
padding: 0.125rem 0.375rem;
border: 1px solid var(--card-border);
border-radius: var(--radius-md);
background-color: var(--muted);
color: var(--muted-foreground);
font-size: 0.875rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}

.step-arrow {
display: flex;
align-items: center;
color: var(--muted-foreground);
}

p {
margin: 0;
}
}

@media (max-width: 60rem) {
.section-head {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}

// One column stacks the cards. A right arrow points nowhere then.
.steps {
grid-template-columns: 1fr;

.step-arrow {
display: none;
}
}
}
</style>
221 changes: 221 additions & 0 deletions src/lib/components/home/QuickStartExamples.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<!--
QuickStartExamples — three real queries on the home page.

Each card opens a share link. The payload carries the query, the name and the
node, so the workbench needs no second parameter.
-->

<script lang="ts">
import { asset, resolve } from '$app/paths';
import BracesIcon from '@lucide/svelte/icons/braces';
import GlobeIcon from '@lucide/svelte/icons/globe';
import MapIcon from '@lucide/svelte/icons/map';
import Button from '@/components/buttons/Button.svelte';
import Card from '@/components/card/card.svelte';
import { Badge } from '@/components/ui/badge/index.js';
import { homeExamples, type HomeExample } from '@/data/home-examples';
import { SHARE_LINK_PATH } from '@/stores/stored-query';

const PENDING_HINT = 'Work in progress';
const METRIC_HINT = 'Measured on a reference run. Your run can differ.';

/** The share link of one example. */
function exampleHref(example: HomeExample): string {
return `${resolve(SHARE_LINK_PATH)}${example.shareQuery}`;
}
</script>

<div class="section-head">
<h2>Quick start examples</h2>
<p class="lead">Three real queries. Open one to review the query in the query builder and plot it on the map.</p>
<span class="pending" title={PENDING_HINT}>
<Button variant="link" disabled>View all examples</Button>
</span>
</div>

<div class="examples">
{#each $homeExamples as example (example.title)}
<Card class="example">
<img
class="shot"
src={asset(`/images/${example.image}`)}
alt="The result of the {example.title} query in the Map Viewer"
width="1382"
height="760"
loading="lazy"
decoding="async"
/>

<div class="body">
<div class="source">
<GlobeIcon />
<span class="source-name">{example.sourceName}</span>
<Badge variant="outline" class="table-name">{example.tableName}</Badge>
</div>

<h3>{example.title}</h3>
<p class="description">{example.description}</p>

<div class="metrics" title={METRIC_HINT}>
<Badge variant="outline" class="rows">{example.rows.toLocaleString()} rows</Badge>
<Badge variant="outline">{example.seconds} s</Badge>
<Badge variant="outline">{example.format}</Badge>
</div>

<div class="actions">
<Button variant="outline" href={exampleHref(example)}>
<BracesIcon />
Check query
</Button>

<span class="pending" title={PENDING_HINT}>
<Button variant="default" disabled>
<MapIcon />
Load map area
</Button>
</span>
</div>
</div>
</Card>
{/each}
</div>

<style lang="scss">
.section-head {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 1rem;

h2 {
margin: 0;
}

.lead {
margin: 0;
flex: 1;
color: var(--muted-foreground);
}
}

// A disabled button drops its pointer events, so a title on the button
// itself never shows. The wrapper keeps the hover, and therefore the hint.
.pending {
display: inline-flex;
}

.examples {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));

// The screenshot reaches the card edge, so the card holds no padding of
// its own. The body below it supplies the padding instead.
:global(.card.example) {
padding: 0;
overflow: hidden;
height: 100%;
}

// `Card` puts every child inside this wrapper. The wrapper must fill the
// card, else `flex` on the body below it has nothing to divide.
:global(.card.example > .card-content) {
display: flex;
flex-direction: column;
flex: 1;
}

.shot {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
object-position: center;
border-bottom: 1px solid var(--card-border);
background-color: var(--muted);
}

.body {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
// The card stretches to the tallest of the row. This pushes the
// actions of every card to the same edge.
flex: 1;

h3 {
margin: 0;
}
}

.source {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.375rem;
color: var(--muted-foreground);
font-size: 0.875rem;

:global(svg) {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}

:global(.table-name) {
font-family: monospace;
}
}

.description {
margin: 0;
// The descriptions differ in length. This keeps the action rows of
// the three cards on one line.
flex: 1;
}

.metrics {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.375rem;

// The row count is the number that a user reads first.
:global(.rows) {
background-color: var(--selected-background);
border-color: var(--border);
color: var(--primary);
}
}

.actions {
display: flex;
flex-direction: row;
gap: 0.5rem;
width: 100%;
margin-top: 0.25rem;

// Both actions share the width. The wrapper must grow the same way
// the sibling button does.
> :global(.btn),
.pending {
flex: 1;
}

.pending :global(.btn) {
width: 100%;
}
}
}

@media (max-width: 60rem) {
.section-head {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
}
</style>
Loading