Your own interface around the map¶
The first TypeScript tutorial put the whole explorer on a page in one element. This one is for the developer who wants the map inside an interface they design themselves, with menus and a search box of their choosing, a results list, and a panel that shows whatever the reader clicks. We'll keep Mosaica's map and build everything around it in plain TypeScript, with no framework.
The data this time is 50,000 papers from arXiv, the preprint server, laid out so that papers on similar subjects sit near each other. By the end you'll have a page with a menu of arXiv's archives filled from the server, a title search, a date range, a live count and the titles of the matching papers in view, a panel that opens the paper you click, and the map coloured by topic with each topic named. Clicking a topic's name narrows everything to it. Along the way you'll meet the store, the object that every Mosaica element reads from, and learn how a page of your own talks to it.
It takes about twenty minutes, and assumes you have done the first tutorial. You need the same things it did: mosaica installed, Node.js 22.15 or later with npm, and the Mosaica repository cloned to ~/mosaica.
Here is what we'll do.
- Start Mosaica over the arXiv sample.
- Make a page with a bare map in a layout of our own.
- Drive the map's filters from the page's own controls.
- Show a count and a list of the matching papers.
- Open the paper the reader clicks in the page's own panel.
- Colour the papers by topic, name the topics, and filter to one.
Everything goes in ~/papers, with Mosaica's files in ~/papers/mosaica and the web project in ~/papers/web.
Start Mosaica¶
This step follows the first tutorial's, with different data. Make a directory for the server and copy this example's settings into it:
mkdir -p ~/papers/mosaica
cd ~/papers/mosaica
cp ~/mosaica/clients/ts/examples/own-ui/mosaica/mosaica.toml .
[bundle]
path = "bundle"
[serve]
viewer = "127.0.0.1:9241"
session = "127.0.0.1:9242"
control = "127.0.0.1:9243"
open = true
The settings are the first tutorial's apart from the ports. The page will read the map from port 9241, and the session and control addresses move to 9242 and 9243, so this server can run beside the first tutorial's if that one is still going. open = true again lets anything on this machine read the data without signing in.
The arXiv sample has its own schema. Each paper has a title, which the schema marks for search, and an arxiv_id. Its archive and primary_category are categories, the archive being the broad area, such as math or astro-ph, and the primary category the subject class within it, such as math.FA for functional analysis. submitted_at is when it was submitted. The schema also declares two layouts of the papers, which Mosaica calls views, and two groupings of them, which it calls layers: arXiv's own classification, and topics a clustering program found in the layout and a language model named. We'll come back to the topics in the last step.
Build the bundle from the sample, then start the server in a terminal of its own:
mosaica build --config ~/mosaica/examples/data/arxiv/schema.toml
...
built /home/you/papers/mosaica/bundle (v00000): 50000 items, 175 terms, 86144 pairs, 15024822 bytes on disk, 0 artifact(s) minted, 0 unclustered member row(s), 0 row(s) refused
view topics: 50000 row(s)
view topics-wide: 50000 row(s)
50000 items is one per paper, and each of the two views places all of them.
mosaica serve
...
2026-10-25T18:12:08.235884Z WARN mosaica_server: serve.open is set: this server is open to this machine, and every request, with or without a credential, sees and may change everything
{"event":"listening","viewer":"127.0.0.1:9241","session":"127.0.0.1:9242","control":"127.0.0.1:9243"}
A map in a layout of your own¶
The explorer from the first tutorial is made of smaller elements, and you can use them on their own. <mosaica-map> is the map canvas. The explorer adds a filter panel of field cards, an item card for the point you click, a layer picker and a few more. Each of them reads from one shared object, which Mosaica calls the store.
The store is the part of Mosaica's client that has no pixels. It asks the server for what the camera can see, holds the filters, the layers and the colouring, and publishes the results as projections. A projection is a named piece of state, such as view for the counts or selection for the clicked item, and the store replaces it whole each time it changes. The elements draw the projections. Your own code can read them too, and can tell the store what to do. <mosaica-store> is an element that hands one store to every Mosaica element inside it.
Make the web project as before, and install the elements and the client they are built on. @mosaicajs/client holds the store.
cd ~/papers
npm create vite@latest web -- --template vanilla-ts --no-interactive
cd web
npm install @mosaicajs/components @mosaicajs/client
rm -r src/counter.ts src/assets public/icons.svg
Here is the whole page. Replace index.html with it:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>arXiv papers</title>
</head>
<body>
<header>
<h1>arXiv papers</h1>
</header>
<main>
<div id="controls">
<label>
Archive
<select id="archive">
<option value="">All archives</option>
</select>
</label>
<label>
Title
<input id="title" type="search" placeholder="Words in the title" />
</label>
<fieldset>
<legend>Submitted</legend>
<label>From <input id="from" type="date" /></label>
<label>To <input id="to" type="date" /></label>
</fieldset>
<label class="toggle">
<input id="topics" type="checkbox" />
Show topic names
</label>
<p id="topic" hidden>
Topic: <strong></strong>
<button type="button">Clear</button>
</p>
</div>
<mosaica-store>
<mosaica-map title-field="title"></mosaica-map>
</mosaica-store>
<aside>
<section id="paper" hidden>
<h2></h2>
<p class="archive"></p>
<p><a target="_blank">Open on arXiv</a></p>
</section>
<p id="count"></p>
<ol id="results"></ol>
</aside>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
The left column holds our controls, the right one a panel for a paper, a count and a list, and between them a <mosaica-map> inside a <mosaica-store>. The map has one attribute, title-field="title", so hovering over a paper shows its title rather than its id. None of the controls do anything yet: the rest of the tutorial wires them up one at a time.
Vite keeps the page's scripts and stylesheet in src. Replace style.css there with a three-column grid:
html, body {
height: 100%;
margin: 0;
}
body {
display: grid;
grid-template-rows: auto 1fr;
font: 14px/1.4 system-ui, sans-serif;
}
header {
padding: 12px 20px;
border-bottom: 1px solid #ddd;
}
h1 {
margin: 0;
font-size: 20px;
}
main {
display: grid;
grid-template-columns: 240px 1fr 320px;
min-height: 0;
}
#controls {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px 20px;
border-right: 1px solid #ddd;
}
#controls label,
#controls fieldset {
display: flex;
flex-direction: column;
gap: 4px;
}
#controls fieldset {
margin: 0;
padding: 8px 10px 10px;
border: 1px solid #ddd;
}
#controls .toggle {
flex-direction: row;
align-items: center;
}
aside {
padding: 16px 20px;
border-left: 1px solid #ddd;
overflow-y: auto;
}
#paper {
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #ddd;
}
#paper h2 {
margin: 0 0 6px;
font-size: 16px;
}
#results {
padding-left: 20px;
}
#results li {
margin-bottom: 6px;
}
mosaica-map {
--mosaica-map-height: 100%;
}
Most of it is ordinary layout. The last rule matters: the map is 420 pixels tall unless told otherwise, and --mosaica-map-height: 100% makes it fill its grid cell instead. <mosaica-store> takes no space of its own, so the map is the grid's middle column.
Now make the store. Create mosaica.ts in src:
import {createStore} from '@mosaicajs/client';
export const store = createStore({viewerUrl: 'http://localhost:9241'});
createStore makes a store that talks to the server at viewerUrl, the viewer address from mosaica.toml. We give no token, which an open server accepts. We export the store because our own code will talk to it from the next step on.
Replace main.ts with:
import '@mosaicajs/components';
import {store} from './mosaica.ts';
import './style.css';
document.querySelector('mosaica-store')!.store = store;
The first line defines the elements. The last hands our store to <mosaica-store>, and through it to the map. Start the development server on port 5273, which keeps it clear of the first tutorial's page:
npm run dev -- --port 5273
> [email protected] dev
> vite --port 5273
VITE v8.3.5 ready in 236 ms
➜ Local: http://localhost:5273/
➜ Network: use --host to expose
Open http://localhost:5273/. Each dot is a paper, and a tight clump is a group of papers on one narrow subject. The map's own toolbar is at its top left: pan, select a box, draw a lasso, and fit the map to what matches. The controls in the left column do nothing yet.
Drive the filters from the page¶
A filter in Mosaica is applied on the server, so the map and every count are computed over the papers that match. To filter from code, give the store a filter draft. A draft holds one control per field, and each control takes the shape that suits its kind of field: a list of keys for a category, a search query for text, and a lower and upper bound for a number or a time. An empty control constrains nothing. store.setFilters takes the whole draft, and the store asks the server again for everything on screen.
The archive menu should list the archives the data has, so we ask the server rather than typing them in. Create filters.ts:
import type {Store} from '@mosaicajs/client';
const DAY_MS = 86_400_000;
/** Microseconds since 1970 at the start of a date input's day, or `null` where it is empty. */
function startOf(date: string): number | null {
return date ? Date.parse(date) * 1000 : null;
}
/** Microseconds since 1970 at the last instant of a date input's day, or `null` where it is empty. */
function endOf(date: string): number | null {
return date ? (Date.parse(date) + DAY_MS) * 1000 - 1 : null;
}
export function filters(store: Store): void {
const archive = document.querySelector<HTMLSelectElement>('#archive')!;
const title = document.querySelector<HTMLInputElement>('#title')!;
const from = document.querySelector<HTMLInputElement>('#from')!;
const to = document.querySelector<HTMLInputElement>('#to')!;
// Ask how many papers each archive holds, and list the archives in that order.
store.setAggregate('archives', {
subject: 'visible',
groupings: [{count: true, by: {field: 'archive', top: 50}}]
});
const unsubscribe = store.subscribe('aggregates', (aggregates) => {
const rows = aggregates.get('archives')?.result?.tables[0]?.rows;
if (!rows) return;
for (const row of rows) {
if (row.group !== 'listed') continue;
const count = Number(row.count).toLocaleString('en-GB');
archive.add(new Option(`${row.title ?? row.key} (${count})`, row.key));
}
unsubscribe();
});
function apply(): void {
store.setFilters({
filter: {
archive: {family: 'category', keys: archive.value ? [archive.value] : []},
title: {family: 'text', query: title.value, phrase: true},
submitted_at: {family: 'numeric', gte: startOf(from.value), lte: endOf(to.value)}
},
highlight: {}
});
}
archive.addEventListener('change', apply);
title.addEventListener('input', apply);
from.addEventListener('change', apply);
to.addEventListener('change', apply);
}
store.setAggregate asks the server to count the papers in groups, here one group per archive, and keeps the answer in the aggregates projection under the name we gave it, archives. subject: 'visible' counts every paper the viewer may see and ignores the filters, so the menu stays the same while the reader filters. Without it the counts would follow the filters, which suits a panel that shows how many match but not a menu. top: 50 asks for up to fifty archives, largest first, which is more than the 38 in arXiv's list.
store.subscribe('aggregates', ...) calls our function each time that projection is replaced. The answer is a table with a row per archive: its key, such as math, its title from the archive vocabulary, such as Mathematics, and its count. The menu shows the title and sends the key. Rows in the listed group are the archives themselves, as opposed to a row for everything outside the top fifty. Once the menu is filled we unsubscribe.
apply builds the draft from the controls. The archive's control is a category control with the chosen key, or none for All archives. The title's is a text control. Its query is read as search words, all of which must appear, in any order, and phrase: true lets the reader put a phrase in double quotes. Submission times are a timestamp field, and Mosaica counts a timestamp in microseconds since the start of 1970, so startOf and endOf turn a date from the date inputs into the first and last microsecond of that day. Each control calls apply whenever it changes.
Add the filters to main.ts, which now reads:
import '@mosaicajs/components';
import {filters} from './filters.ts';
import {store} from './mosaica.ts';
import './style.css';
document.querySelector('mosaica-store')!.store = store;
filters(store);
Save, and open the Archive menu. It lists Computer Science (10,478) first, then Mathematics (10,123) and Astrophysics (6,084), down to archives arXiv has since folded into others, such as Superconductivity, with a paper or two each. Choose General Relativity and Quantum Cosmology, and the map keeps about a thousand dots, most of them in a few clumps. Type black hole in the title box and fewer remain. Set From to 1 January 2020 and fewer again. We can't see the counts yet, so that's the next thing to build.
A count and a list of your own¶
We want two things on the right: how many papers match, and the titles of the first twenty. Both should cover the part of the map on screen, and follow the filters and the camera.
The count is already in the store. Its view projection has an inView part, which holds counts over the camera's box: visible, how many papers the viewer can see there, and matched, how many of those the filters let through. The store asks for them once the camera has been still for a quarter of a second.
The titles are not. A map needs only the points, so the store never fetches titles, and we ask for them ourselves. store.items reads the records of the papers that match the store's filters, page by page, with the same token and view the map uses. Create results.ts:
import {formatMasked, type Store} from '@mosaicajs/client';
export function results(store: Store): void {
const count = document.querySelector<HTMLParagraphElement>('#count')!;
const list = document.querySelector<HTMLOListElement>('#results')!;
const map = document.querySelector('mosaica-map')!;
// How many papers in the part of the map on screen match the filters.
store.subscribe('view', (view) => {
if (view.inView?.status !== 'shown') return;
const matched = formatMasked(view.inView.matched);
const visible = formatMasked(view.inView.visible);
count.textContent = `${matched} of the ${visible} papers in view match`;
});
let reading: AbortController | null = null;
let timer = 0;
// The titles of the first 20 of those papers, in the order they lie on the map.
async function listFirst(): Promise<void> {
reading?.abort();
const controller = (reading = new AbortController());
try {
const read = await store.items({fields: ['title'], inView: true, order: 'map', pageRows: 20, pages: 1}, controller.signal);
const page = await read.next();
await read.return();
const titles: string[] = page.done ? [] : page.value.getChild('title')!.toArray();
list.replaceChildren(...titles.map((title) => Object.assign(document.createElement('li'), {textContent: title})));
} catch (error) {
if (!controller.signal.aborted) throw error;
}
}
// Ask once the camera or the filters have rested for a quarter of a second.
function soon(): void {
window.clearTimeout(timer);
timer = window.setTimeout(listFirst, 250);
}
map.addEventListener('mosaica-viewchange', soon);
store.subscribe('filters', soon);
}
The count comes from subscribing to view. formatMasked writes a count with thousands separators, and marks one that is not exact.
For the list, store.items takes the fields to return, here only title. inView: true limits the read to the area the in-view counts cover, which is the part of the map on screen, so the list and the count agree. pageRows: 20 and pages: 1 ask for one page of twenty, and order: 'map' returns them in the order they lie on the map. We read that first page and stop the read with read.return().
The map fires mosaica-viewchange each time the camera moves, and the store publishes filters each time they change, so both ask for the list again. soon waits until they have been still for a quarter of a second before asking, so a pan sends one request rather than one per frame. An AbortController cancels a read the next one replaces.
Add it to main.ts:
import '@mosaicajs/components';
import {filters} from './filters.ts';
import {store} from './mosaica.ts';
import {results} from './results.ts';
import './style.css';
document.querySelector('mosaica-store')!.store = store;
filters(store);
results(store);
The page reloads with the filters cleared and reads "50,000 of the 50,000 papers in view match", with twenty titles under it. Choose General Relativity and Quantum Cosmology again and the count drops to 1,177. Type black hole and it reads 133, and every title in the list mentions a black hole. Set From to 1 January 2020 and 45 remain.
Now clear the filters and zoom into one of the clumps with the mouse wheel. The count covers only what is on screen, so both figures fall, and the list changes to papers from the part you're looking at. Every figure is computed from the papers this viewer is allowed to see. On an open server that is all of them, and on a server with users each person's counts and lists cover only what they may see.
Open a paper in your own panel¶
When the reader clicks a dot, the map asks the server for that paper's record and fires mosaica-itemopen when it arrives. The event carries the record, with the paper's fields keyed by name. Create paper.ts:
import type {Store} from '@mosaicajs/client';
export function paper(store: Store): void {
const panel = document.querySelector<HTMLElement>('#paper')!;
const heading = panel.querySelector('h2')!;
const archive = panel.querySelector<HTMLParagraphElement>('.archive')!;
const link = panel.querySelector('a')!;
const map = document.querySelector('mosaica-map')!;
let shown = '';
map.addEventListener('mosaica-itemopen', async (e) => {
shown = e.detail.id;
const fields = e.detail.record.fields;
heading.textContent = String(fields.title);
link.href = `https://arxiv.org/abs/${fields.arxiv_id}`;
archive.textContent = '';
panel.hidden = false;
const title = await store.titleOf('archive', String(fields.archive));
if (shown !== e.detail.id) return;
archive.textContent = `Archive: ${title ?? fields.archive}`;
});
map.addEventListener('mosaica-miss', () => {
panel.hidden = true;
});
}
The title and the link come straight from the record. arXiv's address for a paper is https://arxiv.org/abs/ followed by its identifier, so arxiv_id makes the link. The archive is stored as its key, such as gr-qc, and store.titleOf asks for the title the archive vocabulary gives that key. A vocabulary is the list of values a category field may take, each with a key and a title. shown remembers which paper the panel is for, so a slow answer about a previous paper doesn't overwrite it. A click on empty map fires mosaica-miss, and the panel closes.
Add two lines to main.ts, the import and paper(store); at the end:
import {paper} from './paper.ts';
paper(store);
With General Relativity and Quantum Cosmology chosen and black hole typed, click a dot. Its title appears at the top of the right column, with "Archive: General Relativity and Quantum Cosmology" and a link that opens the paper on arXiv. The map draws a ring round the dot you picked. Click an empty part of the map and the panel closes.
Topics¶
The sample comes with topics. A clustering program called Toponymy grouped the papers by where they sit on the map, at four levels from 16 broad topics down to 574 narrow ones, and a language model wrote a name for each cluster from the titles of a few of its papers. In Mosaica the clusters are one layer, clusters/toponymy, and their names another, topics/toponymy, attached to the clusters. Each cluster and each name is an artifact: something the server keeps alongside the papers, with its own mosaica_id.
Create topics.ts:
import type {Store} from '@mosaicajs/client';
export function topics(store: Store): void {
const map = document.querySelector('mosaica-map')!;
const toggle = document.querySelector<HTMLInputElement>('#topics')!;
const chosen = document.querySelector<HTMLParagraphElement>('#topic')!;
map.colourBy = 'cluster:clusters/toponymy';
toggle.addEventListener('change', () => {
map.layers = toggle.checked ? ['topics/toponymy'] : [];
});
map.addEventListener('mosaica-artifactopen', (e) => {
const artifact = BigInt(e.detail.id);
const name = store.get('artifacts').attached.get(artifact) ?? 'Unnamed topic';
store.setMembers([{layer: e.detail.detail.layer, artifact, outside: false, verb: 'filter', label: name}]);
chosen.querySelector('strong')!.textContent = name;
chosen.hidden = false;
});
chosen.querySelector('button')!.addEventListener('click', () => {
store.setMembers([]);
chosen.hidden = true;
});
}
map.colourBy colours the dots. Given cluster: and a layer, it gives each cluster a colour and each paper its cluster's. The checkbox sets map.layers, the layers the map draws. Asking for the names draws the clusters they name as well. These are the same properties as the colour-by and layers attributes, set from code here because the checkbox changes them.
Clicking a topic's name, or anywhere inside its outline, opens the cluster, and the map fires mosaica-artifactopen once its details arrive. The event's id is the artifact's mosaica_id as a decimal string, since it doesn't fit in a JavaScript number, and detail.layer is its layer. The name comes from the store's artifacts projection, whose attached map holds each cluster's name by its id.
store.setMembers filters to an artifact's members, the papers in the cluster. It takes a list of clauses, each naming an artifact by layer and id, with outside: false to keep the members and verb: 'filter' to narrow the map to them. The other verb, highlight, would keep every paper and light the members. Member clauses join the filters, so the topic combines with the archive, title and dates. Clear passes an empty list.
Add the import and the call to main.ts, which is now complete:
import '@mosaicajs/components';
import {filters} from './filters.ts';
import {store} from './mosaica.ts';
import {paper} from './paper.ts';
import {results} from './results.ts';
import {topics} from './topics.ts';
import './style.css';
document.querySelector('mosaica-store')!.store = store;
filters(store);
results(store);
paper(store);
topics(store);
The page reloads with every paper coloured. Tick "Show topic names", and names such as Nanodevices and AI Systems appear over the clusters. Click "Nanodevices", or a spot inside its outline. The map keeps only that cluster, the left column shows the topic, and the count reads "1,242 of the 50,000 papers in view match". The list fills with its papers: graphene photodetectors, silicon waveguides, plasmonic sensors and optical modulators. Type graphene in the title box to search within the topic, and 71 papers remain. Empty the box and press Clear to bring every paper back.
What you built¶
You built a page with an interface of its own around Mosaica's map. Its menu is filled from the server, and its filters reach the map and every count through one call to the store. Its list reads the records on screen, and its panels react to what the reader clicks.
Along the way you used four ways for a page to work with Mosaica. You changed what is shown with the store's setFilters and setMembers and the map's colourBy and layers properties. You read the store's projections view, aggregates and artifacts. You asked the server for more on request, with store.items and store.titleOf. And you listened to the map's events mosaica-viewchange, mosaica-itemopen, mosaica-miss and mosaica-artifactopen.
The finished project is in the clone, in clients/ts/examples/own-ui/, with the same files in mosaica and web.
Next¶
- The
@mosaicajs/clientreference lists the store's methods and projections,MosaicaClient, and the helpers that build filter drafts and member clauses. - The components reference lists every element with its properties and events, for adding the item card, the layer picker or a field card to a page like this one.
- In React,
@mosaicajs/reactgives the store as hooks, and@mosaicajs/react/componentsevery element as a component.