Developers

Split Control's graphics are plain web files: HTML, CSS and JavaScript modules following the EBU OGraf standard. A look is a family of graphics sharing one style. This page shows how to make your own and test it.

Preview tool: try every look with a simulated race, or load your own look's zip, right in your browser.

Open the preview

Start here

You can try the graphics in your browser before installing anything. For a simple colour change, start with an existing look. To make a new graphic, you need a text editor and basic HTML, CSS and JavaScript; the shared code handles playback for you.

I want to…Start withWhat you need
Change colours or the event logoPlan → Theme in the appNo coding
Change fonts, spacing or animationsChange an existing lookCSS and a downloaded look zip
Add a new graphic with my own textYour first graphicThree small code changes, shown below
Show a class’s runners or resultsInputs and feedsA desk declaration and a timing datasource file
Test or share my workTest and ShareThe browser preview, then the app

The words used in this guide

WordPlain meaningExample
LookA folder of graphics with the same visual style.standard or sof
GraphicOne thing you put on screen.A start list or a name plate
ManifestA JSON file describing a graphic and its controls.standard-results.ograf.json
KindThe drawing/type of graphic. CSS uses it in a class name.results → .gfx--results
Desk keyThe stable identifier used by favourites, command numbers and remote controls.timer uses the clock drawing
SchemaThe list of editable fields, their types and defaults.title is a text field
ViewA JavaScript function that turns data into HTML.Returns { html, steps }
FeedA type of data the app can fill in.timing or weather
StepOne press of Next. It may change the page or focus a runner.SOFT Runner presentations

Commands in a terminal run from the repository folder. Code blocks labelled with a filename go in that file. Keep JSON keys in double quotes, and leave out trailing commas.

Preview tool

The preview runs the real graphics, the same files the app uses, without installing anything:

Load the same zip again after a change: it replaces the earlier copy. Remove loaded looks clears them all.

How a look works

The app provides 17 standard drawings through sets/_core/. A look can use these drawings, leave graphics out, or add a custom drawing with its own Live desk declaration. Runner presentations and Finish watch use shared views with per-look styling; IOF and ČO omit them. Shared style and motion live in the look’s CSS.

sets/
├── _core/                 shared code (ships with the app, not part of a look)
│   ├── graphic.js         the OGraf Web Component: lifecycle, pages, clocks, scaling
│   ├── views.js           data → markup for standard graphics
│   └── base.css           placement and structure shared by all looks
└── my-look/               one look
    ├── set.json           name, description, the three theme colours, approvedEventsOnly
    ├── set.js             durations, name styles, labels (read by the graphics)
    ├── theme.css          the style and the motion
    ├── fonts/             fonts.css, font files and licence (see Fonts)
    └── my-look-results/   one folder per graphic: manifest + a 4-line graphic.js
        ├── my-look-results.ograf.json
        └── graphic.js

A graphic's graphic.js is only this:

import { createGraphic } from '../../_core/graphic.js';
import set from '../set.js';
export default createGraphic('results', set);

Because graphics import ../../_core/, a look always runs with the shared code of the app it's in. That keeps every look up to date with new features and fixes.

Change an existing look

For new colours, fonts or details on a look you already use, no tools are needed (for a new font, see Fonts):

  1. In Split Control, Graphics library → Export… next to the look, or download it from the Looks page.
  2. Unzip it and edit theme.css (and set.json for the name and colours). Keep the look's id ("id" in set.json).
  3. Zip the look's folder again and load it in the preview to check it.
  4. Import it in the app: it replaces the look with the same id. For a built-in look like Standard, removing your copy brings the original back.

Make a new look

A new look needs its own id, and its graphics need unique ids. The repository has a generator that writes all 17 graphic folders for you.

  1. Get the code: git clone https://github.com/opensourceorienteering/O-TV-Graphics, then npm install (Node.js 22 or newer).
  2. Copy a look that is close to what you want, e.g. templates/sets/standard to templates/sets/my-look, and delete its graphic folders (standard-*).
  3. Edit set.json (id, name, description, colours; add "approvedEventsOnly": true if the look copies an organisation's official graphics, so it is marked "Only use at approved events") and set.js (id, and colorVars: the CSS variables the three theme colours set).
  4. Add the look to SETS in scripts/generate-template-sets.js and run npm run generate:sets.
  5. Style it in theme.css. To see changes, either run the app (npm run dev, then Scan library), or zip the folder and load it in the preview.

Every look in the repository is a working example. standard is a good start; sof follows a full graphic manual. See also templates/sets/README.md.

Your first graphic: a welcome card

This example adds a small, editable welcome card to your local Standard look. It needs no competition, runners or internet data. Use a working copy of the repository; editing this copy does not change anyone else’s installed app.

1. Install and start the app

Install Node.js 22 or newer. Open a terminal, clone the repository and install its dependencies. Native NDI dependencies may need the platform build tools listed in the repository README.

git clone https://github.com/opensourceorienteering/O-TV-Graphics.git
cd O-TV-Graphics
npm install
npm run dev

If your shell has Electron’s Node mode enabled, start with env -u ELECTRON_RUN_AS_NODE npm run dev on macOS/Linux. Split Control’s dev server uses port 5175; another project can use 5173 at the same time.

2. Create the manifest

Create the folder templates/sets/standard/standard-welcome/. Inside it, create standard-welcome.ograf.json with this complete JSON:

{
  "$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
  "id": "standard-welcome",
  "name": "Standard – Welcome card",
  "main": "graphic.js",
  "supportsRealTime": true,
  "supportsNonRealTime": false,
  "stepCount": 1,
  "schema": {
    "type": "object",
    "properties": {
      "title": { "type": "string", "title": "Heading", "default": "Welcome" },
      "message": { "type": "string", "title": "Message", "default": "Good luck to every runner!" }
    }
  },
  "v_olgrafik_set": "standard",
  "v_olgrafik_kind": "welcome-card",
  "v_olgrafik_zone": "main",
  "v_olgrafik_desk": {
    "label": "Welcome card",
    "short": "Welcome",
    "inputs": [],
    "required": [],
    "fields": ["title", "message"],
    "feeds": [],
    "behaviours": []
  }
}

schema.properties creates the two text fields. fields puts them in the Live inspector. Empty inputs and feeds mean there is no class/runner selection and no timing connection to set up.

3. Add the drawing

In the same folder, create graphic.js:

import { createGraphic } from '../../_core/graphic.js';
import { esc } from '../../_core/util.js';
import set from '../set.js';

function welcomeCard(data) {
  const title = data.title || 'Welcome';
  const message = data.message || 'Good luck to every runner!';
  return {
    steps: 1,
    html: `<div class="panel panel--table">
      <header class="hdr"><span class="hdr-title">${esc(title)}</span></header>
      <div class="rows"><div class="row">
        <span class="c-name">${esc(message)}</span>
      </div></div>
    </div>`
  };
}

export default createGraphic('welcome-card', set, welcomeCard);

esc() safely inserts text into HTML. createGraphic() supplies Show, Hide, updates and cleanup. The view just describes what to draw.

4. Give the new kind a position

Add this CSS at the end of templates/sets/standard/theme.css:

.gfx--welcome-card .panel {
  top: var(--table-top, 144px);
  left: var(--table-x, 480px);
  right: var(--table-x, 480px);
}
.gfx--welcome-card .row { padding: 16px; }

The picture is designed at 1920 × 1080. These shared table settings give the card the same centred width as Standard’s results and start list. New kinds need a positioning rule; otherwise their panel can sit at the top left.

5. See it on screen

  1. In Graphics library, scan the library (or restart the dev app).
  2. Create/open a test event and choose Standard as its look.
  3. Open Live and choose Welcome card. Type a heading and message.
  4. Check Preview, then press Show. Edit the message and press Update; press Hide when finished.

Success looks like: a centred card using Standard’s fonts and colours, two editable fields in Live, and changes reaching Program only when you press Update.

6. Try two pages

In the manifest, change stepCount to -1 (the drawing decides its step count). Replace only the view function with this; keep the imports and export:

function welcomeCard(data, ctx) {
  const messages = [data.message || 'Welcome!', 'The next class starts soon.'];
  const page = Math.min(Math.max(ctx.step, 1), messages.length);
  return {
    steps: messages.length,
    pageKey: `page-${page}`,
    html: `<div class="panel panel--table">
      <header class="hdr"><span class="hdr-title">${esc(data.title || 'Welcome')}</span></header>
      <div class="rows"><div class="row"><span class="c-name">${esc(messages[page - 1])}</span></div></div>
      <footer class="ftr"><span class="ftr-page">${page}/${messages.length}</span></footer>
    </div>`
  };
}

Rescan/restart, hide the card if it is on air, then Show it again. Use Next/Previous to move between the two pages. For operator-controlled steps, add "manualSteps" to the declaration’s behaviours array. For focus changes on one page, return the same pageKey for those steps.

Add a custom graphic

Add a hand-made OGraf package alongside the standard generated folders. Its manifest declares v_olgrafik_kind, v_olgrafik_zone and an optional v_olgrafik_desk with its label, inputs, required inputs, manual fields, feeds, behaviours and preview data. Known kinds inherit defaults; invalid declarations fall back and show a warning in Graphics library.

"v_olgrafik_desk": {
  "label": "Relay overview",
  "inputs": ["class", "page"],
  "required": ["class"],
  "fields": ["title"],
  "feeds": ["timing"],
  "behaviours": ["holdOnAir", "titleFromClass"],
  "previewData": "results"
}

Declare manual fields in schema.properties and supply a datasource file for timing mappings. A custom view is passed to createGraphic(kind, set, view). It returns { html, steps?, pageKey?, key?, rekey? }. Shared lifecycle code handles pages, clocks and live patches. A new kind also needs an explicit CSS placement rule using tokens such as --table-top and --table-x.

Use pageKey for focus changes within one visible page and manualSteps to disable automatic page timers. SOFT’s runner picker uses a string-array field with v_olgrafik_control: "runner-multi-select" and v_olgrafik_default: "favourites". Stable desk keys retain command numbers and favourites when changing looks. Companion reads the current event’s catalogue.

The template developer guide contains the complete contract and examples.

Command and option reference

There are three kinds of “command”: terminal commands for development, methods that the app calls on a graphic, and operator controls in Live. The tables separate them.

Terminal commands · Graphic methods and actions · What a custom view returns · Desk declaration properties · Inputs · Feeds · Behaviours · Existing standard drawings · Live keyboard controls · Common problems

Terminal commands

Run in the repository folderWhat it doesWhen to use it
npm run devStarts the dev app and Vite on port 5175.While editing a look
npm run type-checkChecks TypeScript without building.Before sharing code changes
npm test -- --runRuns the unit and backend integration tests once.After app-side changes
npm run test:templatesLoads every repository graphic in real Chromium and saves screenshots/report.After changing markup, CSS or a manifest
npm run generate:setsRewrites the standard generated graphic packages.After editing the generator; keep custom folders separate
npx vite buildBuilds the app code without making an installer.To check the production bundle
npm run buildType-checks, builds and makes an installer for your platform.To package the application
bash scripts/build-site.sh /tmp/split-control-siteBuilds the site, previews and look downloads. Replaces the target directory.To test the website in a dedicated output folder
bash scripts/zip-looks.sh /tmp/split-control-looksCreates one downloadable zip per look.To share updated looks

Graphic methods and actions

The app calls these methods for you. These examples are useful when writing a test or a custom player; graphic means an already-created graphic element.

Method/actionExample callResult
loadawait graphic.load({ data: { title: 'Welcome' } })Loads styles and prepares the graphic. It is still off air.
playActionawait graphic.playAction({})Shows an off-air graphic; when already visible, advances one step.
playAction with deltaawait graphic.playAction({ delta: -1 })Moves back one step when visible.
playAction with gotoawait graphic.playAction({ goto: 1 })Goes to a specific one-based step.
updateActionawait graphic.updateAction({ data: { title: 'Updated' } })Replaces the data object and patches the visible content. Send the full data you want to retain.
stopActionawait graphic.stopAction({})Plays the exit and hides the graphic.
customActionawait graphic.customAction({ id: 'next-page' })Built-in alias for the next step.
customActionawait graphic.customAction({ id: 'previous-page' })Built-in alias for the previous step.
customActionawait graphic.customAction({ id: 'first-page' })Built-in alias for step 1.
disposeawait graphic.dispose()Stops ticking and removes the graphic’s DOM/listeners.

Add skipAnimation: true to play, stop or update options when you need an instant state in a test. A “page” action advances a step; a custom graphic can use steps for runner focus instead of page changes.

What a custom view returns

PropertyExampleMeaning
htmlhtml: '<div class="panel">Welcome</div>'Required HTML string. Escape any operator/timing text with esc().
stepssteps: 3Total operator steps. Omit for a one-step graphic.
pageKeypageKey: 'page-1'Keep the key the same for in-place focus steps; change it for a page transition.
rekey + keyrekey: true, key: 'next-start-group'For content that changes with time. The ticker redraws when the key changes; do not generate a new key on every call.

Desk declaration properties

PropertyExampleMeaning
key"welcome-card"Stable control/favourite ID. Defaults to the kind for new graphics.
label / short"Welcome card" / "Welcome"Full Live label and short palette label.
inputs["class", "page"]Palette parameters, in the order you want them typed.
required["class"]Inputs needed to prepare the graphic; each must also be in inputs.
fields["title", "message"]Schema fields to show in the Live inspector.
settingsFields["nameStyle"]Schema fields to edit only in template settings.
feeds["timing"]Existing data sources the graphic uses; timing needs a datasource mapping file too.
behaviours["holdOnAir", "manualSteps"]Existing desk behaviours; see below.
rowsPartner"start-list"A graphic whose rows-per-page setting this graphic can follow.
previewData"results", "splits" or "starts"What Plan needs to find in real timing data, otherwise it uses samples. Use null to clear an inherited requirement.

Inputs

NameWhat the operator picks/typesExample
classA class number or name.Class 2, or D21
runnerA bib or runner name.Bib 101
controlA radio control or finish; start requires startControl.45, F; S when enabled
pageA one-based starting step/page number.2

Feeds

NameSuppliesSetup
timingClass results, split times and start times.Timing connection plus a .datasource.json mapping file
courseCourse length, climb, controls and other supported class facts.A class and a source that supplies course facts; manual values can override
weatherCondition, temperature, wind and humidity.Event location/coordinates, or manual weather values
eventEvent title, series, place and date.Event setup

Behaviours

NameWhat changesRequirement
startControlAllows S (start); no control also means start.control input
followNextStarterFollows the next starter until the operator makes a manual pick.runner input; the current Live desk has one shared follow state
freezeEnables manually stopping a running clock on the way to a control.runner input; no freeze at the start
titleFromClassUses the class name when the title is empty.A chosen class
titleFromLocationUses the event location when the title is empty.Event location
holdOnAirDefaults live timing updates to held until Update.Useful for tables and presentations
manualStepsDisables automatic page timers. Only operator actions advance steps.Useful for presentations

Existing standard drawings

Pass the kind to createGraphic(kind, set) to reuse its drawing. Desk keys can differ from kinds. Two legacy drawings have no default Live entry; add a desk declaration to offer them there.

KindGraphicDefault desk key
clockElapsed timertimer
course-infoCourse parameterscourse-info
event-titleRace titleevent-title
free-textIdentity platefree-text
name-strapName strapNo default Live entry
podiumPodium / flowerspodium
position-historyPosition historyposition-history
resultsResults tableresults
runner-identityRunner identityrunner-identity
runner-lower-thirdRunner lower thirdNo default Live entry
runner-splitsRunner split comparisonrunner-splits
runner-startRunner focusrunner-start
speakerCommentators / speakerspeaker
standings-lower-thirdLive splitlive-feed
start-groupStart groupstart-group
start-listStart liststart-list
weatherWeatherweather

All looks except IOF and ČO declare runner-presentations and finish-watch. Its Next action lifts chosen runners one at a time; start-list favourites are selected by default. See the Runner presentations guide. Finish watch uses a prewarning to reveal runners, shows total race time and class placing, and freezes at a measuring point before removing completed rows. See the Finish watch guide.

Live keyboard controls

Use these in Live with focus outside a text field. Command numbers are configurable—check the current desk rather than assuming a fixed number.

KeyAction
SpaceShow/hide the selected graphic
UUpdate its on-air data from Preview
HHide the active graphic
← / →Previous/next step on Program
TFreeze/resume an eligible running clock
EscHide all; in a field, leave the field
FSave the draft as a favourite
Ctrl/Cmd+KOpen the command palette, including from a field

Palette example: if your Results graphic is number 4, 4 2 F 1 means Results, class 2, finish, first page. 4 F1 recalls that graphic’s first saved favourite. Preparing a command changes Preview; Show puts it on air.

Common problems

What you seeCheck this
My graphic is missing from Live.Check the event’s selected look, unique manifest ID and desk key, and declaration warnings in Graphics library. Rescan after adding a package.
The panel is small or at the top left.Add a placement rule for your exact .gfx--kind; reuse table tokens for a results-sized panel.
The inspector has no field.Declare it in schema.properties and list it in v_olgrafik_desk.fields.
The graphic has no timing results.Check the class, event timing connection, timing feed and .datasource.json mappings. A feed name alone does not fetch a new data type.
My data disappears after Update.updateAction receives a replacement data object. Retain the fields you still need.
Next changes the whole page when I only want to focus a runner.Keep the same pageKey for those steps; use manualSteps to disable timers.
The dev server reports a busy port.Stop the process using 5175 and restart. Other projects may keep using 5173.

Optional class filters and radio selectors

A timing datasource can set api.classScope: "optional". When the operator leaves Class empty, mapping combines all known classes and live polling follows them; each result retains its class. A selected class uses the normal one-class path.

A string schema field with v_olgrafik_control: "radio-control" gets a radio selector. Add v_olgrafik_includeFinish: true to offer Finish, and v_olgrafik_distinctFrom: "triggerControl" to require a different point. Finish watch is the complete example. Stateful custom views receive their graphic element as this; keep state per element so Preview and Program stay independent.

Styling the graphics

Start theme.css with @import url('../_core/base.css');. The canvas is 1920 × 1080; graphics scale to the output size.

Markup

.gfx.gfx--<kind>The graphic, e.g. .gfx--results. Carries data-state (see Motion).
.panel.panel--<type>One block: --table, --lower, --strap, --side, --title, --clock, --podium, --chart…
.hdr / .ftrHeader and footer: .hdr-title, .hdr-sub, .hdr-logo, .ftr-page…
.rows > .rowTable rows. --i is the row's index, for staggered animation. .row--leader, .row--hl (the runner in focus), .row--status (MP, DNF…).
Cells.c-place, .c-name, .c-id (club, flag), .c-time, .c-bib, .c-start, .c-label, .c-value, .c-change

Variables

Placement is set with variables, so a look rarely needs to move panels itself: --margin-x, --margin-y, --table-top, --table-x, --lower-w, --side-w, --title-x, --podium-x. Chart lines use --line-0 to --line-7.

Theme colours

Operators can change three colours per event (Plan → Theme). colorVars in set.js names the CSS variables they set, and colors in set.json gives the defaults, in the same order. Derive every other shade from those three (color-mix(), relative colours), so a change follows everywhere.

Rules every look follows

A look can carry a logo of its own, such as a federation's. In Plan, operators choose Use template logo, Upload custom logo or No logotype. The graphic tells the look which one with data-logo on .gfx: template, custom or none.

  1. Put the logo file in the look's folder and name it in set.json, so Plan can show it: "logo": "logo.svg". Use an official file unaltered, and only one you may share.
  2. The simple way: add logo: new URL('./logo.png', import.meta.url).href to set.js. The graphics then show it in the logo boxes, where an uploaded logo would go. Standard, Bold, Brutal, Signal and Contours carry the Split Control logo this way.
  3. Or place it yourself (IOF and Svensk Orientering do): leave logo out of set.js and draw it in theme.css only for template, so a custom logo replaces it and "No logotype" hides it:
    .gfx[data-logo="template"] .panel--title .title-body::after {
      content: ""; position: absolute; right: 30px; top: 24px; width: 160px; height: 66px;
      background: url(./logo.svg) center / contain no-repeat;
    }
  4. The uploaded logo goes into the logo boxes (.hdr-logo, .title-logo, .ftr-logo, .clk-logo) only with custom; style them as before.

Try all three with Logo in the preview.

Fonts

A look carries its own fonts. Production computers are often borrowed or freshly installed, so the look must not depend on fonts installed on the computer. If it does, it looks different on every machine, or falls back to Arial on air.

What goes in the look

my-look/
├── set.js                 fonts: points at fonts/fonts.css
├── theme.css              --font: "Libre Franklin", Arial, sans-serif;
└── fonts/
    ├── fonts.css          one @font-face per weight and style
    ├── libre-franklin-latin-600-normal.woff2
    ├── libre-franklin-latin-ext-600-normal.woff2
    └── LICENSE-libre-franklin.txt

Font stacks

Put the bundled font first, then a common system font and a generic family:

.gfx {
  --font: "Libre Franklin", Arial, sans-serif;
}

Don't put an installed font in front of the bundled one, even a brand font. Computers that have it (Franklin Gothic comes with Microsoft Office, for example) would show a different look from those that don't.

Brand fonts you can't share

Many federation and broadcaster fonts are commercial (ITC Franklin Gothic for the IOF, GT Planar for ČO). Bundle a free stand-in that is close in shape and width, and say so in set.json; the Looks page shows it:

"fonts": "Libre Franklin (OFL), bundled; it stands in for ITC Franklin Gothic"

A broadcaster who owns the brand font can make their own copy of the look with the font files in fonts/, keeping the copy to themselves.

Where to get the files

Fontsource has most open fonts already split by weight and subset, with ready-made @font-face rules: download the font, copy the .woff2 files you need into fonts/, and change the url()s to ./file.woff2. The looks in the repository were made this way.

Check it

Motion

The graphic sets data-state on .gfx; animate with CSS keyframes for each state. The durations in set.js say how long each state lasts.

inEntering (durations.in)
onOn air. Live changes flash with .is-changed; rows slide to their new place.
step-out / stepChanging page (durations.stepOut, durations.step)
leaveLeaving (durations.out)
outHidden

Animate only transform and opacity: they stay smooth in NDI capture. Don't follow the computer's Reduce Motion setting: broadcast output must look the same on every machine.

The data graphics get

The app sends the same data to every look. The main fields:

results[]Runners at the shown timing point: name, club, country, bib, position, time, diff, statusCode (0 OK, 1 DNS, 2 DNF, 3 MP, 4 DSQ), startTimeMs, splitTimes[]
className, controlCode, splitControls[]Class, timing point (S, a control code or F) and the class's radio controls
runnerName, runnerClub, runnerBibThe runner in focus (lower thirds, identity, runner focus)
title, series, place, date, text, name…Fields the operator fills in
presentationlanguage, identity (club, flag, both), themeColors, logoMode (template, custom, none), logoUrl (only with custom)
clockOffsetMsServer time minus this computer's time, for running clocks

The preview's More data (JSON) box shows and edits these fields.

Test before race day

In the repository, run npm run type-check, npm test -- --run and npm run test:templates. The last command loads every package in real Chromium, exercises the lifecycle and variants, and writes screenshots and a JSON report to the system temporary directory. Synthetic data and hidden rendering do not replace a final check in Live or testing physical NDI/Companion outputs.

Share a look

Other OGraf graphics

Graphics that aren't part of a look also work: any EBU OGraf v1 package (a folder with <name>.ograf.json and its module) can be imported as a zip and appears in Split Control's Graphics library. Split Control also speaks the OGraf Server API at http://<computer>:5657/ograf/v1.