Introduction
Red otter is a self-contained WebGL flexbox layout engine.
Features
- Canvas-like WebGL renderer which supports text rendering and arbitrary polygons (by triangulating them).
- Text rendering is based on a font atlas texture that uses SDF (signed distance field). This allows for smooth scaling and upscaling up to some extent (see example below). You can see how the texture looks here.
- TTF file parser that produces glyph atlas texture. The parser is quite simple and definitely won't parse all possible TTF files, but work on support for more features is in progress.
- Layout engine which resembles Facebook Yoga. as it roughly implements CSS-like flexbox layout. It supports most of the properties and has some limited styling capabilities. API is designed to resemble React Native styling.
-
JSX support with DOM-like elements:
<view>
,<text>
,<shape>
. - Full TypeScript support. IDE will guide through creating elements and applying styles. All incorrect props will be easily detected.
- No dependencies. The whole library is hand-crafted bare minimum of code required to do the job.
Why?
There are apps (probably not websites but apps) that require very minimal rendering overhead, maybe they need to be lightweight and don't care about having persistent component tree or local state management (or anyway they need a fine-grained, custom solution for this). I am not the first person to notice similar thing [1].
It's also very common need of game developers to have a capable UI library. For example Unity UI Toolkit is using Yoga as a layout engine, which should be a nice illustration why this library might be very useful for WebGL applications.
If you find yourself writing some WebGL and at any point think "I wish there was a simple way to render text and maybe some UI on top of it", then this library might be for you.
In terms of software philosophy, think about products such as Warp or Zed. They are extremely fast programs which implement their own GPU rendering.
What this is not
-
This is not React. There is no virtual DOM, no
reconciliation, no state management. This is a layout engine, not a
full-blown framework. For instance, there are no components, only
elements (you can use
<view>
but cannot define your own<HeaderView>
).
- Not suitable for making websites. It doesn't utilize DOM, has absolutely no accessibility support. It is created for making UIs for games and other UI-heavy applications and should work absolutely great there, but this is not general-purpose tool.
- A compiler or other metaprogramming-based solution. It is just a library which happens to use JSX which is supported by various bundlers and IDEs.
What is it then?
Think about it as a lightweight Unity UI Toolkit for WebGL. TypeScript-first Dear ImGui .
Those are bold claims and current state is far from it, but this actually gives a good picture of the roadmap.
Install
yarn add red-otter
To render text you will also need to generate the font atlas. See guide.
Quick start
For a quick start, try playing with CodeSandbox example:
Enabling JSX
Using JSX syntax is entirely optional and if you don't want to use it, you will actually save yourself a lot of potential trouble with tricking your IDE to understand it and possibly you can also avoid stepping into uncharted territories of making React and another JSX-based library live in one project.
If you want to see comparison of JSX and non-JSX syntax, see Why JSX section.
For editor to correctly highlight JSX, add to
compilerOptions
in tsconfig.json
:
{
"compilerOptions": {
// ...
"jsx": "react-jsx",
"jsxImportSource": "red-otter/dist"
}
}
You will likely need to configure your bundler to enable JSX (in case of React or Preact this is usually done automatically with some plugin).
Vite
Add following to your vite.config.ts
:
import { defineConfig, Plugin } from "vite";
function jsx(): Plugin {
return {
name: "jsx",
config() {
return {
esbuild: {
jsx: "automatic",
jsxImportSource: "red-otter",
},
};
},
};
}
export default defineConfig({
// ...
plugins: [jsx()],
});
Parcel
Make sure that your .parcelrc
config supports static files
via raw transformer:
{
"extends": "@parcel/config-default",
"transformers": {
"url:*": ["@parcel/transformer-raw"]
}
}
And then use a special import (in this example runtime loading, but the same principle applies to pregenerated data):
import fontURL from "url:./public/inter.ttf";
// ...
const fontFace = new FontFace("Inter", `url("${fontURL}")`);
// ...
const file = await fetch(fontURL);
Examples
All code present below follows similar pattern. You need to create
<canvas>
element, load font (either with pregenerated
data or in runtime, see guide),
initiatilize Context
and if you want to just draw shapes
and polygons that's it. If you want to utilize layout engine (you
probably do), then you need to add Layout
and use one of
available syntaxes to declare elements (see
Why JSX for comparison).
import { Font, Context, Layout } from "red-otter";
const canvas = document.createElement("canvas");
const scale = window.devicePixelRatio;
canvas.width = 800 * scale;
canvas.height = 600 * scale;
const div = document.getElementById("app");
div.appendChild(canvas);
const font = new Font({
spacingMetadataJsonURL: "/spacing.json",
spacingBinaryURL: "/spacing.dat",
UVBinaryURL: "/uv.dat",
fontAtlasTextureURL: "/font-atlas.png",
});
await font.load();
const context = new Context(canvas, font);
context.clear();
const layout = new Layout(context);
layout.add(
<view style={{ width: 100, height: 100, backgroundColor: "#fff" }}>
<text style={{ fontFamily: font, fontSize: 20, color: "#000" }}>Hello</text>
</view>
);
layout.render();
context.flush();
First example
Example layout made with red-otter
. Everything is rendered
using the library.
Show code
const container: Style = {
width: "100%",
height: "100%",
backgroundColor: zinc[900],
};
const menu: Style = {
flexDirection: "row",
gap: 24,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: zinc[800],
alignSelf: "stretch",
borderBottomWidth: 1,
borderColor: zinc[700],
};
const content: Style = {
padding: 24,
gap: 16,
flex: 1,
};
const headerText: TextStyle = {
color: "#fff",
fontFamily: font,
fontSize: 24,
};
const text: TextStyle = {
color: zinc[400],
fontFamily: font,
fontSize: 14,
};
const overlay: Style = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.4)",
justifyContent: "center",
alignItems: "center",
};
const dialog: Style = {
backgroundColor: zinc[800],
borderRadius: 10,
borderWidth: 1,
borderColor: zinc[700],
};
const headerLine: Style = {
padding: 20,
flexDirection: "row",
justifyContent: "space-between",
alignSelf: "stretch",
alignItems: "center",
};
const closeButton: Style = {
backgroundColor: zinc[700],
width: 24,
height: 24,
justifyContent: "center",
alignItems: "center",
borderRadius: 12,
borderWidth: 1,
borderColor: zinc[600],
};
const separator: Style = {
backgroundColor: zinc[700],
height: 1,
alignSelf: "stretch",
};
const paragraphs: Style = {
gap: 16,
padding: 24,
};
const row: Style = {
flexDirection: "row",
gap: 12,
padding: 20,
alignSelf: "flex-end",
};
const buttonSecondary: Style = {
backgroundColor: zinc[600],
paddingHorizontal: 16,
justifyContent: "center",
alignItems: "center",
borderRadius: 8,
height: 32,
borderWidth: 1,
borderColor: zinc[500],
};
const buttonText: TextStyle = {
color: zinc[100],
fontFamily: font,
fontSize: 15,
};
const buttonPrimary: Style = {
paddingHorizontal: 16,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#2563eb",
borderRadius: 8,
height: 32,
borderWidth: 1,
borderColor: "#3b82f6",
};
const buttonPrimaryText: TextStyle = {
color: "#fff",
fontFamily: font,
fontSize: 15,
};
const checkboxLine: Style = {
flexDirection: "row",
gap: 12,
alignItems: "center",
};
const checkbox: Style = {
height: 20,
width: 20,
justifyContent: "center",
alignItems: "center",
borderRadius: 3,
backgroundColor: "#2563eb",
borderWidth: 1,
borderColor: "#3b82f6",
};
const footer: Style = {
flexDirection: "row",
gap: 24,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: zinc[800],
alignSelf: "stretch",
justifyContent: "space-around",
borderTopWidth: 1,
borderColor: zinc[700],
};
layout.add(
<view style={container}>
<view style={menu}>
<text style={text}>File</text>
<text style={text}>Edit</text>
<text style={text}>Run</text>
<text style={text}>Terminal</text>
<text style={text}>Window</text>
<text style={text}>Help↗</text>
</view>
<view style={content}>
<text style={headerText}>Welcome to Red Otter!</text>
<text style={text}>
I am a self-contained WebGL flexbox layout engine. I can render
rectangles, letters and polygons.
</text>
<text style={text}>
I can do flexbox layout, position: absolute, z-index. Everything you
would expect from a layout engine!
</text>
<text style={text}>
I am also quite not bad at styling: as you can see I can handle rounded
corners and borders.
</text>
</view>
<view style={footer}>
<text style={text}>main*</text>
<text style={text}>Ln: 1257, Col 38</text>
<text style={text}>UTF-8</text>
<text style={text}>LF</text>
<text style={text}>TypeScript</text>
</view>
<view style={overlay}>
<view style={dialog}>
<view style={headerLine}>
<text style={headerText}>This is a modal</text>
<view style={closeButton}>
<shape
type="polygon"
color={"#fff"}
points={[
[3, 4],
[4, 3],
[8, 7],
[12, 3],
[13, 4],
[9, 8],
[13, 12],
[12, 13],
[8, 9],
[4, 13],
[3, 12],
[7, 8],
]}
/>
</view>
</view>
<view style={separator} />
<view style={paragraphs}>
<text style={text}>
All elements here take part in automatic layout.
</text>
<text style={text}>No manually typed sizes or positions.</text>
<text style={text}>Everything is rendered by the library.</text>
<view style={checkboxLine}>
<view style={checkbox}>
<shape
type="polygon"
color="#fff"
points={[
[3.5, 7],
[6.5, 10],
[12.5, 4],
[14, 5.5],
[6.5, 13],
[2, 8.5],
]}
/>
</view>
<text style={text}>Even the tick icon.</text>
</view>
</view>
<view style={separator} />
<view style={row}>
<view style={buttonSecondary}>
<text style={buttonText}>Cancel</text>
</view>
<view style={buttonPrimary}>
<text style={buttonPrimaryText}>Confirm</text>
</view>
</view>
</view>
</view>
</view>
);
Text
Simple showcase of text rendering. Note that text is using cap height for measuring text height, so some diacritic marks, or letters like 'g' will be cut off and this is intentional behavior. The biggest text is 33% larger than the source font atlas texture, showing upscaling capabilities.
Show code
const text = "Turtle żółw черепаха želva sköldpadda süß Æøñ@ø№→⏎⁂➆§∑¾¤ - - – —";
// NOTE: this showcases calling layout.text(), which is a direct API. You can
// alternatively do:
// ```
// <text style={{ fontFamily: font, fontSize: 64, color: "#fff" }}>{text}</text>
// ```
layout.text(text, font, 64, "#fff", 0, 0);
layout.text(text, font, 32, "#fff", 0, 60);
layout.text(text, font, 16, "#fff", 0, 100);
layout.text(text, font, 12, "#fff", 0, 130);
layout.text(text, font, 10, "#fff", 0, 150);
Justify content
Place content along the main axis.
Show code
const container: Style = {
width: "100%",
height: "100%",
gap: 20,
padding: 20,
alignItems: "stretch",
};
const row: Style = {
flexDirection: "row",
gap: 20,
};
const text: TextStyle = {
fontFamily: font,
};
const red = {
width: 80,
height: 40,
backgroundColor: "#eb584e",
};
const orange = {
width: 80,
height: 40,
backgroundColor: "#ef8950",
};
const yellow = {
width: 80,
height: 40,
backgroundColor: "#efaf50",
};
layout.add(
<view style={container}>
<text style={text}>justifyContent: "start"</text>
<view style={[row, { justifyContent: "flex-start" }]}>
<view style={red} />
<view style={orange} />
<view style={yellow} />
</view>
<text style={text}>justifyContent: "center"</text>
<view style={[row, { justifyContent: "center" }]}>
<view style={red} />
<view style={orange} />
<view style={yellow} />
</view>
<text style={text}>justifyContent: "end"</text>
<view style={[row, { justifyContent: "flex-end" }]}>
<view style={red} />
<view style={orange} />
<view style={yellow} />
</view>
<text style={text}>justifyContent: "space-between"</text>
<view style={[row, { justifyContent: "space-between" }]}>
<view style={red} />
<view style={orange} />
<view style={yellow} />
</view>
<text style={text}>justifyContent: "space-around"</text>
<view style={[row, { justifyContent: "space-around" }]}>
<view style={red} />
<view style={orange} />
<view style={yellow} />
</view>
<text style={text}>justifyContent: "space-evenly"</text>
<view style={[row, { justifyContent: "space-evenly" }]}>
<view style={[red, { flex: 1 }]} />
<view style={orange} />
<view style={yellow} />
</view>
</view>
);
Align items
Place content along the cross axis. Stretch makes item fill cross axis
of parent if width
or height
is not specified.
Show code
const container: Style = {
width: "100%",
height: "100%",
alignItems: "stretch",
gap: 20,
padding: 20,
};
const row: Style = {
flexDirection: "row",
gap: 20,
};
const text: TextStyle = {
fontFamily: font,
};
layout.add(
<view style={container}>
<text style={text}>alignItems: "start"</text>
<view style={[row, { alignItems: "flex-start" }]}>
<view style={{ flex: 1, backgroundColor: "#eb584e", height: 40 }} />
<view style={{ flex: 1, backgroundColor: "#ef8950", height: 60 }} />
<view style={{ flex: 1, backgroundColor: "#efaf50", height: 80 }} />
</view>
<text style={text}>alignItems: "center"</text>
<view style={[row, { alignItems: "center" }]}>
<view style={{ flex: 1, backgroundColor: "#eb584e", height: 40 }} />
<view style={{ flex: 1, backgroundColor: "#ef8950", height: 60 }} />
<view style={{ flex: 1, backgroundColor: "#efaf50", height: 80 }} />
</view>
<text style={text}>alignItems: "end"</text>
<view style={[row, { alignItems: "flex-end" }]}>
<view style={{ flex: 1, backgroundColor: "#eb584e", height: 40 }} />
<view style={{ flex: 1, backgroundColor: "#ef8950", height: 60 }} />
<view style={{ flex: 1, backgroundColor: "#efaf50", height: 80 }} />
</view>
<text style={text}>alignItems: "stretch"</text>
<view style={[row, { alignItems: "stretch" }]}>
<view style={{ flex: 1, backgroundColor: "#eb584e", height: 40 }} />
<view style={{ flex: 1, padding: 20, backgroundColor: "#ef8950" }}>
<text style={{ fontFamily: font }}>height: undefined</text>
</view>
<view
style={{
flex: 1,
padding: 20,
backgroundColor: "#efaf50",
height: 80,
}}
>
<text style={{ fontFamily: font }}>height: 80</text>
</view>
</view>
</view>
);
Align self
Override parent's alignItems
property.
Show code
const container: Style = {
width: "100%",
height: "100%",
alignItems: "stretch",
gap: 20,
padding: 20,
};
const row: Style = {
flexDirection: "row",
gap: 20,
height: 120,
alignItems: "flex-start",
};
const text: TextStyle = {
fontFamily: font,
};
layout.add(
<view style={container}>
<view style={row}>
<view
style={{
flex: 1,
padding: 20,
backgroundColor: "#eb584e",
height: 60,
}}
/>
<view
style={{
flex: 1,
paddingLeft: 20,
backgroundColor: "#ef8950",
height: 60,
justifyContent: "center",
alignSelf: "stretch",
}}
>
<text style={text}>alignSelf: "stretch"</text>
</view>
<view
style={{
flex: 1,
paddingLeft: 20,
backgroundColor: "#efaf50",
height: 60,
justifyContent: "center",
alignSelf: "flex-end",
}}
>
<text style={text}>alignSelf: "end"</text>
</view>
</view>
</view>
);
Flex
Example of flex
property. Note that flex takes precedence
over width
.
Show code
const container: Style = {
width: "100%",
height: "100%",
alignItems: "stretch",
gap: 20,
padding: 20,
};
const row: Style = {
flexDirection: "row",
gap: 20,
};
const text: TextStyle = {
fontFamily: font,
};
layout.add(
<view style={container}>
<text style={text}>flex: 1</text>
<view style={row}>
<view style={{ height: 100, flex: 1, backgroundColor: "#eb584e" }} />
<view style={{ height: 100, flex: 1, backgroundColor: "#ef8950" }} />
<view style={{ height: 100, flex: 1, backgroundColor: "#efaf50" }} />
</view>
<text style={text}>flex: 1, 2, 3</text>
<view style={row}>
<view style={{ height: 100, flex: 1, backgroundColor: "#eb584e" }} />
<view style={{ height: 100, flex: 2, backgroundColor: "#ef8950" }} />
<view style={{ height: 100, flex: 3, backgroundColor: "#efaf50" }} />
</view>
<text style={text}>width: 200, flex: 1, 2</text>
<view style={row}>
<view style={{ height: 100, width: 200, backgroundColor: "#eb584e" }} />
<view style={{ height: 100, flex: 2, backgroundColor: "#ef8950" }} />
<view style={{ height: 100, flex: 1, backgroundColor: "#efaf50" }} />
</view>
</view>
);
Percentage size
Size can be specified using percent units. It is relative to the parent size and does not take into account padding or gaps.
Show code
const container: Style = {
width: "100%",
height: "100%",
padding: 20,
gap: 20,
};
const text: TextStyle = {
fontFamily: font,
};
layout.add(
<view style={container}>
<view
style={{
padding: 5,
width: 120,
height: "50%",
backgroundColor: "#eb584e",
}}
>
<text style={text}>height: "50%"</text>
</view>
<view
style={{
padding: 5,
width: 120,
height: "30%",
backgroundColor: "#ef8950",
}}
>
<text style={text}>height: "30%"</text>
</view>
<view
style={{
padding: 5,
width: 120,
height: "5%",
backgroundColor: "#efaf50",
}}
>
<text style={text}>height: "5%"</text>
</view>
</view>
);
Position relative
By default elements take part in automatic layout calculation.
Show code
const container: Style = {
width: "100%",
height: "100%",
padding: 20,
};
const text: TextStyle = {
fontFamily: font,
};
layout.add(
<view style={container}>
<view style={{ backgroundColor: zinc[900] }}>
<view style={{ padding: 20, backgroundColor: zinc[800] }}>
<text style={text}>Hello, welcome to my layout</text>
</view>
<view style={{ padding: 20, backgroundColor: zinc[700] }}>
<text style={text}>Components have automatic layout</text>
</view>
<view
style={{
flexDirection: "row",
padding: 20,
backgroundColor: zinc[600],
}}
>
<view style={{ padding: 20, backgroundColor: "#eb584e" }}>
<text style={text}>One</text>
</view>
<view style={{ padding: 20, backgroundColor: "#ef8950" }}>
<text style={text}>Two</text>
</view>
<view style={{ padding: 20, backgroundColor: "#efaf50" }}>
<text style={text}>Three</text>
</view>
</view>
</view>
<view
style={{
padding: 20,
marginTop: 20,
width: 200,
aspectRatio: 16 / 9,
backgroundColor: zinc[800],
justifyContent: "center",
alignItems: "center",
}}
>
<text style={text}>aspectRatio: 16 / 9</text>
</view>
</view>
);
Position absolute and z index
Position absolute makes element skip taking part in the layout
calculation and positions it relatively to the parent.
zIndex
is used to declare that element should skip the
order and be higher or lower than siblings. Here it is not used, showing
what happens if we do not interfere with the order of elements in the
layout.
Show code
const container: Style = {
width: "100%",
height: "100%",
padding: 20,
};
const wrapper: Style = {
flexDirection: "row",
gap: 40,
padding: 40,
backgroundColor: zinc[800],
};
const text: TextStyle = {
fontFamily: font,
color: "#fff",
};
const box: Style = {
width: 120,
height: 120,
padding: 20,
};
const absoluteBox: Style = {
backgroundColor: "#ef8950",
position: "absolute",
// zIndex: 1,
right: 0,
bottom: 0,
};
layout.add(
<view style={container}>
<view style={wrapper}>
<view style={[box, { backgroundColor: "#eb584e" }]}>
<text style={text}>1</text>
</view>
<view style={[box, absoluteBox]}>
<text style={text}>2</text>
</view>
<view style={[box, { backgroundColor: "#efaf50" }]}>
<text style={text}>3</text>
</view>
</view>
</view>
);
Left, right, top, bottom
If element has position: relative
, it will take part in the
layout together with siblings and then will be offset by the
coordinates.
If element has position: absolute
, it will not take part in
the layout with siblings and will be positioned relative to the parent's
edges according to those coordinates.
If two opposing coordinates are specified (e.g. left
and
right
) and element has no size specified in that dimension
(width: undefined
), the element will be stretched to fill
the space between them.
Show code
const container: Style = {
width: "100%",
height: "100%",
padding: 20,
gap: 20,
};
const text: TextStyle = {
fontFamily: font,
};
const wrapper: Style = {
flexDirection: "row",
gap: 20,
padding: 20,
backgroundColor: zinc[800],
};
const box: Style = {
width: 120,
height: 120,
padding: 20,
gap: 10,
};
layout.add(
<view style={container}>
<view style={wrapper}>
<view style={[box, { backgroundColor: "#eb584e" }]}></view>
<view style={[box, { backgroundColor: "#ef8950", top: -20, left: -20 }]}>
<text style={text}>top: -20</text>
<text style={text}>left: -20</text>
</view>
<view style={[box, { backgroundColor: "#efaf50" }]}></view>
</view>
<view
style={{
backgroundColor: zinc[700],
left: 200,
right: 200,
height: 100,
justifyContent: "center",
alignItems: "center",
}}
>
<text style={text}>left: 200, right: 200, height: 100</text>
</view>
</view>
);
Mapping over array
As in regular JSX, it's possible to map over an array of elements.
Show code
const container: Style = {
width: "100%",
height: "100%",
padding: 20,
};
const headerCell: Style = {
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: zinc[700],
};
const headerText: TextStyle = {
fontFamily: font,
fontSize: 14,
color: "#fff",
};
const cellText: TextStyle = {
fontFamily: font,
color: zinc[400],
fontSize: 14,
};
const data = [
{ id: 1, name: "One", createdAt: "2021-01-01" },
{ id: 2, name: "Two", createdAt: "2021-01-02" },
{ id: 3, name: "Three", createdAt: "2021-01-03" },
{ id: 4, name: "Four", createdAt: "2021-01-04" },
{ id: 5, name: "Five", createdAt: "2021-01-05" },
{ id: 6, name: "Six", createdAt: "2021-01-06" },
{ id: 7, name: "Seven", createdAt: "2021-01-07" },
{ id: 8, name: "Eight", createdAt: "2021-01-08" },
{ id: 9, name: "Nine", createdAt: "2021-01-09" },
{ id: 10, name: "Ten", createdAt: "2021-01-10" },
{ id: 11, name: "Eleven", createdAt: "2021-01-11" },
{ id: 12, name: "Twelve", createdAt: "2021-01-12" },
{ id: 13, name: "Thirteen", createdAt: "2021-01-13" },
{ id: 14, name: "Fourteen", createdAt: "2021-01-14" },
{ id: 15, name: "Fifteen", createdAt: "2021-01-15" },
];
const columns: { key: keyof (typeof data)[0]; title: string }[] = [
{ key: "id", title: "ID" },
{ key: "name", title: "Name" },
{ key: "createdAt", title: "Created" },
];
layout.add(
<view style={container}>
<view style={{ flexDirection: "row" }}>
{columns.map(({ key, title }) => {
return (
<view style={{ alignItems: "stretch" }}>
<view style={headerCell}>
<text style={headerText}>{title}</text>
</view>
{data.map((item, rowIndex) => {
let content = String(item[key]);
if (key === "createdAt") {
content = new Date(content).toLocaleDateString();
}
return (
<view
style={{
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: rowIndex % 2 === 0 ? zinc[800] : zinc[900],
}}
>
<text style={cellText}>{content}</text>
</view>
);
})}
</view>
);
})}
</view>
</view>
);
Polygons
Example of drawing arbitrary shapes – here a map from OpenStreetMap data with building numbers overlayed on top of their shapes.
Show code
const RADIUS = 6378137.0;
function degreesToMeters(lat: number, lng: number): { x: number; y: number } {
return {
x: (RADIUS * lng * Math.PI) / 180.0,
y: RADIUS * Math.atanh(Math.sin((lat * Math.PI) / 180.0)),
};
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const shapes = map.features
.filter((f) => {
if (f.geometry.type !== "Polygon" && f.geometry.type !== "LineString") {
return false;
}
return true;
})
.map((f) => {
const isRoad = f.properties.highway !== undefined;
const coordinates: [number, number][] = isRoad
? (f.geometry.coordinates as [number, number][])
: (f.geometry.coordinates[0] as [number, number][]);
return {
name: isRoad ? f.properties.name : f.properties["addr:housenumber"],
type: isRoad ? "road" : "building",
points: coordinates.map(([lon, lat]) => {
const { x, y } = degreesToMeters(lat, lon);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
return { x, y };
}),
};
})
.map((polygon) => {
const points = polygon.points.map(({ x, y }) => {
return [
((x - minX) / (maxX - minX)) * 800,
(1 - (y - minY) / (maxY - minY)) * 600,
] as [number, number];
});
return {
name: polygon.name,
type: polygon.type,
center: {
x: points.reduce((acc, [x]) => acc + x, 0) / points.length,
y: points.reduce((acc, [, y]) => acc + y, 0) / points.length,
},
points,
};
});
const container: Style = {
width: "100%",
height: "100%",
backgroundColor: zinc[800],
};
const absolute: Style = {
position: "absolute",
width: "100%",
height: "100%",
};
const text: TextStyle = {
fontFamily: font,
color: "#fff",
fontSize: 14,
};
layout.add(
<view style={container}>
{shapes.map((shape) => {
if (shape.type === "building") {
return (
<view style={absolute}>
<view
style={{
position: "absolute",
left: Math.min(...shape.points.map((p) => p[0])),
top: Math.min(...shape.points.map((p) => p[1])),
}}
>
<shape
type="polygon"
points={shape.points.reverse()}
color={zinc[600]}
/>
</view>
<view
style={{
position: "absolute",
left: shape.center.x,
top: shape.center.y,
zIndex: 1,
}}
>
{shape.name ? (
<view
style={{
backgroundColor: "rgba(0, 0, 0, 0.5)",
width: 32,
height: 32,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
}}
>
<text style={text}>{shape.name}</text>
</view>
) : null}
</view>
</view>
);
}
if (shape.type === "road") {
return (
<view style={absolute}>
<view
style={{
position: "absolute",
left: Math.min(...shape.points.map((p) => p[0])),
top: Math.min(...shape.points.map((p) => p[1])),
}}
>
<shape
type="line"
points={shape.points.reverse()}
thickness={4}
color={zinc[700]}
/>
</view>
</view>
);
}
})}
</view>
);
Border radius and border width
Border radius can be specified for each corner individually, or for all
corners at once. Border width similarly can be specified for each edge
or for all at once. You also need to specify borderColor
to
see the border.
Show code
const text: TextStyle = {
fontFamily: font,
fontSize: 14,
color: "#fff",
};
layout.add(
<view
style={{
padding: 20,
gap: 20,
}}
>
<view
style={{
backgroundColor: zinc[700],
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 2,
borderColor: "--yellow",
borderRadiusTop: 8,
}}
>
<text style={text}>This is a view with a border</text>
</view>
<view
style={{
backgroundColor: zinc[600],
width: 240,
height: 120,
padding: 20,
borderColor: zinc[400],
borderBottomWidth: 2,
borderLeftWidth: 4,
borderRightWidth: 4,
borderTopWidth: 8,
borderRadiusTopLeft: 2,
borderRadiusTopRight: 4,
borderRadiusBottomLeft: 8,
borderRadiusBottomRight: 16,
gap: 8,
}}
>
<text style={text}>This is a view where each</text>
<text style={text}>corner and edge have different</text>
<text style={text}>border and radius</text>
</view>
</view>
);
Generating font atlas
The goal is to allow for both runtime and build step generation.
Worth knowing:
- Full font atlas texture is usually much heavier than font file itself (in case of Inter it is 680kB vs 2MB).
- You can also generate a much smaller subset (for instance just ASCII vs 2.5k characters that Inter has) of the font. Or load only the characters that are actually used in the app. Or start by loading only ASCII to improve startup time and then load the rest on demand.
- There are also two other data files but their weight is around 30kB.
Runtime
The idea is to render font atlas on the fly using browser's canvas API.
It will provide some bandwidth savings (especially if you use a system
font and load a lot of characters – potentially megabytes of download
saved) but might take a bit longer and is more fragile as it requires
browser support for JS font-face
manipulation and properly
functioning <canvas>
.
import { TTF, FontAtlas, Font } from "red-otter";
async function loadFont() {
// Add font to the document so we will use browser to rasterize the font.
const fontFace = new FontFace("Inter", 'url("/inter.ttf")');
await fontFace.load();
document.fonts.add(fontFace);
// Download font file for parsing.
const file = await fetch("/inter.ttf");
const buffer = await file.arrayBuffer();
const ttf = new TTF(buffer);
if (!ttf.ok) {
throw new Error("Failed to parse font file.");
}
// Render font atlas.
const atlas = new FontAtlas(ttf);
const { canvas, spacing } = atlas.render();
const image = new Image();
image.src = canvas.toDataURL();
return new Font(spacing, image);
}
See full app on GitHub.
On CI
Another option is to generate font atlas on CI as a build step of your application. As a side effect, this will make font look precisely the same on all devices (as font rasterization happens on the server).
Example setup for CI rendering:
.
├── ci
│ ├── run.ts # Vercel CI runs this script as part of `yarn build`.
│ │ # The script runs puppeteer and Vite, loads `ci` page
│ │ # and downloads font files to `/public`.
│ ├── index.html # Vite will use this file to show the font atlas page.
│ ├── main.ts # Renders font atlas.
│ └── public
│ └── inter.ttf
├── index.html # Final page. Both HTML files are identical.
├── main.ts # Renders on the final page, loading fonts from `/public`.
CI runtime script can look like this:
import { FontAtlas, TTF } from "red-otter";
async function run(): Promise<void> {
const start = performance.now();
const fontFace = new FontFace("Inter", 'url("/inter.ttf")');
await fontFace.load();
document.fonts.add(fontFace);
const font = await fetch("/inter.ttf");
const buffer = await font.arrayBuffer();
const ttf = new TTF(buffer);
if (!ttf.ok) {
throw new Error("Failed to parse font file.");
}
const atlas = new FontAtlas(ttf);
const { canvas, spacing } = atlas.render();
const div = document.getElementById("app");
if (!div) {
throw new Error("Could not find #app element.");
}
const span = document.createElement("span");
span.setAttribute("style", "display: none");
span.innerText = JSON.stringify(spacing);
div.appendChild(canvas);
div.appendChild(span);
console.debug(
`Font atlas generated in ${(performance.now() - start).toFixed(2)}ms.`
);
}
run();
index.html
is very simple:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Font atlas</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>
Finally, there's a script that runs Vite server and Puppeteer to render the page and save the results to the filesystem.
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { createServer } from "vite";
import chromium from "@sparticuz/chromium";
import { launch, PuppeteerLaunchOptions } from "puppeteer-core";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PNG_FILE = `${__dirname}/../public/font-atlas.png`;
const JSON_FILE = `${__dirname}/../public/spacing.json`;
const BINARY_FILE = `${__dirname}/../public/spacing.dat`;
const UV_FILE = `${__dirname}/../public/uv.dat`;
const BUNDLER_PORT = 3456;
function printSize(size: number): string {
if (size < 1024) {
return `${size} B`;
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} kB`;
} else {
return `${(size / 1024 / 1024).toFixed(2)} MB`;
}
}
async function getPuppeteerOptions(): Promise<Partial<PuppeteerLaunchOptions>> {
if (process.env.CI === "1") {
return {
executablePath: await chromium.executablePath(),
args: [...chromium.args, "--no-sandbox"],
headless: chromium.headless,
};
} else if (process.platform === "darwin") {
return {
executablePath:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
args: ["--no-sandbox"],
headless: true,
};
} else {
throw new Error("Unsupported OS.");
}
}
function saveFile(
filePath: string,
data: string | Int16Array | Float32Array,
encoding: "utf-8" | "binary"
): void {
fs.writeFileSync(filePath, data, { encoding });
const fileOnDisk = fs.statSync(filePath);
console.debug(`Saved ${filePath} ${printSize(fileOnDisk.size)}`);
}
async function run(): Promise<void> {
const server = await createServer({
root: path.resolve(__dirname, "."),
server: { port: BUNDLER_PORT },
});
await server.listen();
console.debug(`Vite dev server started on port ${BUNDLER_PORT}.`);
const browser = await launch(await getPuppeteerOptions());
console.debug("Chromium launched.");
const page = await browser.newPage();
page
.on("console", (message) => {
const type = message.type();
console.debug(`${type}: ${message.text()}`);
})
.on("pageerror", ({ message }) => console.debug(message))
.on("response", (response) => {
const status = response.status().toString();
console.debug(`${`HTTP ${status}`} ${response.url()}`);
})
.on("requestfailed", (request) => {
console.debug(`${request.failure().errorText} ${request.url()}`);
});
// Because of CORS it has to be served by a server.
await page.goto(`http://localhost:${BUNDLER_PORT}`);
await page.waitForSelector("canvas");
const canvas = await page.$("canvas");
if (!canvas) {
throw new Error("Canvas not found.");
}
console.debug("Page loaded.");
await canvas.screenshot({ path: PNG_FILE, omitBackground: true });
console.debug(`Saved ${PNG_FILE}.`);
const spacing = await page.$eval("span", (element) => {
return JSON.parse(element.innerHTML);
});
const { glyphs, uvs, ...metadata } = spacing;
const spacingJson = JSON.stringify(metadata, null, 2);
saveFile(JSON_FILE, spacingJson, "utf-8");
const glyphBinary = new Int16Array(glyphs);
saveFile(BINARY_FILE, glyphBinary, "binary");
const uvBinary = new Float32Array(uvs);
saveFile(UV_FILE, uvBinary, "binary");
await browser.close();
await server.close();
}
run();
Call it as part of yarn build
:
{
"scripts": {
"build": "yarn font-atlas && vite build",
"font-atlas": "yarn run ts-node --esm --swc --experimentalSpecifierResolution=node ci/run.ts"
}
}
Example of logs from running in the Vercel CI:
$ /vercel/path0/node_modules/.bin/ts-node -O '{"module":"nodenext"}' ci/run.ts
Vite dev server started on port 3456.
Chromium launched.
HTTP 200 http://localhost:3456/
HTTP 200 http://localhost:3456/main.ts
HTTP 200 http://localhost:3456/@vite/client
HTTP 200 http://localhost:3456/@fs/vercel/path0/node_modules/vite/dist/client/env.mjs
debug: [vite] connecting...
debug: [vite] connected.
HTTP 200 http://localhost:3456/@fs/vercel/path0/node_modules/.vite/deps/red-otter.js?v=c4488a07
HTTP 200 http://localhost:3456/inter.ttf
HTTP 304 http://localhost:3456/inter.ttf
log: Font atlas generated in 1155.50ms.
Page loaded.
Saved /vercel/path0/ci/../public/font-atlas.png.
Saved /vercel/path0/ci/../public/spacing.json.
Saved /vercel/path0/ci/../public/spacing.dat.
Saved /vercel/path0/ci/../public/uv.dat.
Full example on GitHub.
Why JSX
Compare code written with the direct API, probably a simplest form of immediate mode GUI:
const container: Style = {
width: "100%",
height: "100%",
padding: 20,
};
const text: TextStyle = {
fontFamily: font,
};
// Those code blocks are useless but increase readability.
layout.view(container);
{
layout.view({ backgroundColor: zinc[900] });
{
layout.view({ padding: 20, backgroundColor: zinc[800] });
layout.text("Hello, welcome to my layout", text);
layout.end();
layout.view({ padding: 20, backgroundColor: zinc[700] });
layout.text("Components have automatic layout", text);
layout.end();
layout.view({
flexDirection: "row",
padding: 20,
backgroundColor: zinc[600],
});
{
layout.view({ padding: 20, backgroundColor: "#eb584e" });
layout.text("One", text);
layout.end();
layout.view({ padding: 20, backgroundColor: "#eb584e" });
layout.text("Two", text);
layout.end();
layout.view({ padding: 20, backgroundColor: "#eb584e" });
layout.text("Three", text);
layout.end();
}
layout.end();
}
layout.end();
}
layout.end();
Then with calling HyperScript-style function manually. Basically using JSX in plain JavaScript:
const container: Style = {
width: "100%",
height: "100%",
padding: 20,
};
const text: TextStyle = {
fontFamily: font,
};
layout.add(
h(
"view",
{ style: container },
h(
"view",
{ style: { backgroundColor: zinc[900] } },
h(
"view",
{ style: { padding: 20, backgroundColor: zinc[800] } },
h("text", { style: text }, "Hello, welcome to my layout")
),
h(
"view",
{ style: { padding: 20, backgroundColor: zinc[700] } },
h("text", { style: text }, "Components have automatic layout")
),
h(
"view",
{
style: {
flexDirection: "row",
padding: 20,
backgroundColor: zinc[600],
},
},
h(
"view",
{ style: { padding: 20, backgroundColor: "#eb584e" } },
h("text", { style: text }, "One")
),
h(
"view",
{ style: { padding: 20, backgroundColor: "#eb584e" } },
h("text", { style: text }, "Two")
),
h(
"view",
{ style: { padding: 20, backgroundColor: "#eb584e" } },
h("text", { style: text }, "Three")
)
)
)
)
);
And finally JSX, which is easily supported by probably all bundlers and IDEs:
const container: Style = {
width: "100%",
height: "100%",
padding: 20,
};
const text: TextStyle = {
fontFamily: font,
};
layout.add(
<view style={container}>
<view style={{ backgroundColor: zinc[900] }}>
<view style={{ padding: 20, backgroundColor: zinc[800] }}>
<text style={text}>Hello, welcome to my layout</text>
</view>
<view style={{ padding: 20, backgroundColor: zinc[700] }}>
<text style={text}>Components have automatic layout</text>
</view>
<view
style={{
flexDirection: "row",
padding: 20,
backgroundColor: zinc[600],
}}
>
<view style={{ padding: 20, backgroundColor: "#eb584e" }}>
<text style={text}>One</text>
</view>
<view style={{ padding: 20, backgroundColor: "#ef8950" }}>
<text style={text}>Two</text>
</view>
<view style={{ padding: 20, backgroundColor: "#efaf50" }}>
<text style={text}>Three</text>
</view>
</view>
</view>
</view>
);
JSX has couple advantages here. It is the shortest. Arguably this syntax makes it more readable (which is for me not always the case with XML). And something that is harder to reason about just looking at the code – it is much easier for me to refactor than function calls above. Very clear boundaries of components (opening and closing tags) make it easier to move blocks around consciously.
API reference
Style
Styling available for views. 44 properties in total.
Undefined means that view should hug its content.
String can only be a percentage value (e.g. '50%'
). It
is defined relative to the parent view.
Numerical value is defined in pixels.
Undefined means that view should hug its content.
String can only be a percentage value (e.g. '50%'
). It
is defined relative to the parent view.
Percentage value does not take into account paddings or gaps.
Numerical value is defined in pixels.
Direction of children layout.
How children are aligned along the main axis.
How children are aligned along the cross axis.
Override parent's alignItems
property for this child.
How space is distributed among children along the main axis.
Position absolute
makes the view skip taking part in
the layout.
Space between children along the main axis.
Z-index of the view. Higher value means that the view will be drawn on top of values with lower z-index.
Default value is 0.
Supported formats are: hex, RGB, HSL, HSV.
Hex can be in short form (e.g. #fff
) or long form (e.g.
#ffffff
).
RGB (rgb(255, 0, 0)
) can also have alpha channel:
rgba(255, 0, 0, 0.5)
.
HSL (hsl(60, 100%, 50%)
) can also have alpha channel:
hsla(30, 60%, 90%, 0.8)
. Commas are optional (e.g.
hsl(60 100% 50%)
). Alpha channel can also be separated
by /
(e.g. hsla(30 60% 90% / 0.8)
).
Exactly the same rules apply to HSV as to HSL.
You can pass readCSSVariables
to
options
argument in Layout()
constructor
and then you can use CSS variables in color values by their names,
ie. var(--my-color)
is accessed by
my-color
.
Whether the view should be visible or not.
If view is positioned absolute
, this property is used
to define its position relative to the parent view.
If view is positioned relative
, this property is an
offset from the calculated layout position (but it doesn't affect
layout of siblings).
If width
is not defined and both left
and
right
are set, then the element will stretch to fill
the space between the two offsets. Similarly for
height
.
See: top
property.
See: top
property.
See: top
property.
Color of the border. See backgroundColor
for supported
formats.
Width of the border. Default value is <code>0</code>.
Border width at the top edge of the view.
Border width at the right edge of the view.
Border width at the bottom edge of the view.
Border width at the left edge of the view.
Corner radius. Default value is <code>0</code>.
Overrides borderRadius
property.
Overrides borderRadius
property.
Overrides borderRadius
property and
borderRadiusTop
property.
Overrides borderRadius
property and
borderRadiusTop
property.
Overrides borderRadius
property and
borderRadiusBottom
property.
Overrides borderRadius
property and
borderRadiusBottom
property.
Space around children. More specific properties override it.
Overrides padding
property.
Overrides padding
property.
Overrides margin
and
marginHorizontal
properties.
Overrides margin
and
marginHorizontal
properties.
Overrides margin
and
marginVertical
properties.
Overrides margin
and
marginVertical
properties.
Space around children. More specific properties take precedence.
Overrides margin
property, less important than
marginLeft
or marginRight
.
Overrides margin
property, less important than
marginTop
or marginBottom
.
Overrides margin
and
marginHorizontal
properties.
Overrides margin
and
marginHorizontal
properties.
Overrides margin
and
marginVertical
properties.
Overrides margin
and
marginVertical
properties.
Layout
Layout is a tree of views. Use it via JSX API (<view>
etc.) or direct API (view()
and text()
).
constructor(context: IContext, options?: LayoutOptions)
Takes a context instance which is used to retrieve HTML canvas size.
view(style: Style): void
Adds a new view to the layout. Any subsequent calls to
view()
and text()
will add children to this
view. Call end()
to return to the parent view.
Alternative to JSX API. Used in combination with
frame()
and end()
.
Usage:
layout.view(containerStyle);
layout.text("Hello", font, 12, "#000", 10, 10);
layout.end();
end(): void
Makes the parent view the current view, so that subsequent calls to
view()
and text()
will add children to the
parent view instead.
text(
text: string,
font: Font,
fontSize: number,
color: string,
x?: number,
y?: number,
trimStart?: number,
trimEnd?: number
): void
Adds a new text view to the layout.
Alternative to JSX API.
add(node: TreeNode<FixedView>): void
Add a subtree to the layout. Can be used interchangeably with direct API
(view()
and text()
) if needed.
Can be also called multiple times.
Usage:
layout.add(
<view style={container}>
<text style={{ fontFamily: font, fontSize: 12, color: "#fff" }}>Hello</text>
</view>
);
getRoot(): TreeNode<FixedView>
calculate(): void
Calculates layout tree by applying all sizing and direction properties.
render(): void
Render the tree to the context. Most of the time it means that this step is what takes UI to appear on the screen.
Font
Class that holds font spacing data and font atlas image. For loading
font file see FontAtlas
.
constructor(
files:
| {
spacingMetadataJsonURL: string;
spacingBinaryURL: string;
fontAtlasTextureURL: string;
UVBinaryURL: string;
}
| {
spacingMetadata: FontAtlasMetadata;
spacingBinaryURL: string;
fontAtlasTextureURL: string;
UVBinaryURL: string;
}
)
constructor(spacing: Spacing, fontImage: HTMLImageElement)
Initialize font by providing URLs to font files or by providing spacing data and font atlas image, which are all already loaded.
load(): Promise<void>
Load font data from provided URLs. Not needed if font was initialized with preloaded data.
getTextShape(
text: string,
fontSize: number
): {
boundingRectangle: { width: number; height: number };
positions: Vec2[];
sizes: Vec2[];
}
Calculates shape information for a given text string and font size.
getGlyphs(): Map<number, Glyph>
Returns a map of all glyphs in the font.
getUV(code: number): Vec4
Returns texture coordinates for a given character code.
getMetadata(): FontAtlasMetadata
Returns metadata for the font.
getFontImage(): HTMLImageElement
Returns the font image.
loadFontImageAsync(): Promise<HTMLImageElement>
Context
Context is a low level drawing API, resembling Canvas API from browsers. Used by layout engine to draw elements. This is a reference implementation that should work well in most cases.
constructor(canvas: HTMLCanvasElement, font: Font)
Creates new context.
getWebGLContext(): WebGL2RenderingContext
Get WebGL2 context.
getCanvas(): HTMLCanvasElement
Get canvas element.
getFont(): Font
Get font.
line(points: Vec2[], thickness: number, color: Vec4): void
Draw line connecting given list of points.
polygon(points: Vec2[], color: Vec4): void
Triangulates and draws given polygon. Assumes that polygon is convex. Vertices must be in clockwise order.
triangles(points: Vec2[], color: Vec4): void
Draw given list of triangles.
rectangle(
position: Vec2,
size: Vec2,
color: Vec4,
borderRadius?: Vec4,
borderWidth?: Vec4,
borderColor?: Vec4
): void
Draw rectangle. Border radius is optional and needs to be specified for each corner, in order: top-left, top-right, bottom-right, bottom-left. Border width is specified for each edge, in order: top, right, bottom, left.
Color and border color are normalized RGBA vectors, ie.
new Vec4(1, 0, 0, 1)
is red.
clear(): void
Clears the screen.
setProjection(x: number, y: number, width: number, height: number): void
Sets projection matrix to orthographic with given dimensions. Y axis is
flipped - point (0, 0)
is in the top left.
text(
text: string,
position: Vec2,
fontSize: number,
color: Vec4,
trimStart?: Vec2,
trimEnd?: Vec2
): void
Writes text on the screen.
Optionally accepts trimStart
and trimEnd
,
which represent top left and bottom right corners of the text bounding
box. Text will be trimmed to fit inside the box.
flush(): void
Renders to screen and resets buffers.
loadTexture(image: HTMLImageElement): WebGLTexture
Load texture to GPU memory.
setAttribute(
index: number,
size: number,
normalized: boolean,
stride: number,
offset: number
): number
pushVertexData(
position: Vec2,
uv: Vec2,
color: Vec4,
corner: Vec2,
size: Vec2,
radius: Vec4,
borderWidth: Vec4,
borderColor: Vec4
): void
FontAtlas
Takes array of glyph sizing quads and calculates tight packing of them into a texture atlas.
constructor(ttf: TTF, alphabet?: string)
Alphabet is the subset of characters that will be included in the atlas. If not specified, all characters in the font will be included.
render(): { canvas: HTMLCanvasElement; spacing: Spacing }
Returns a canvas with the font atlas rendered on it.
TTF
Parses TTF font files to read information about available characters, their sizes and spacing information.
constructor(data: ArrayBuffer)
Vec2
A 2D vector.
constructor(x: number, y: number)
add(other: Vec2): Vec2
subtract(other: Vec2): Vec2
length(): number
normalize(): Vec2
scale(scalar: number): Vec2
cross(other: Vec2): number
dot(other: Vec2): number
distance(other: Vec2): number
lerp(other: Vec2, t: number): Vec2
equalsEpsilon(other: Vec2, epsilon: number): boolean
equals(other: Vec2): boolean
toString(): string
Vec3
A 3-dimensional vector.
constructor(x: number, y: number, z: number)
add(other: Vec3): Vec3
subtract(other: Vec3): Vec3
length(): number
normalize(): Vec3
scale(scalar: number): Vec3
cross(other: Vec3): Vec3
dot(other: Vec3): number
distance(other: Vec3): number
lerp(other: Vec3, t: number): Vec3
equalsEpsilon(other: Vec3, epsilon: number): boolean
equals(other: Vec3): boolean
toString(): string
Vec4
A 4-dimensional vector.
constructor(x: number, y: number, z: number, w: number)
add(other: Vec4): Vec4
subtract(other: Vec4): Vec4
length(): number
normalize(): Vec4
scale(scalar: number): Vec4
cross(other: Vec4): Vec4
dot(other: Vec4): number
distance(other: Vec4): number
lerp(other: Vec4, t: number): Vec4
xyz(): Vec3
equalsEpsilon(other: Vec4, epsilon: number): boolean
equals(other: Vec4): boolean
toString(): string
Mat4
constructor(data: number[])
identity(): Mat4
scale(x: number, y: number, z: number): Mat4
translate(x: number, y: number, z: number): Mat4
xRotation(angle: number): Mat4
yRotation(angle: number): Mat4
zRotation(angle: number): Mat4
rotate(x: number, y: number, z: number): Mat4
orthographic(
left: number,
right: number,
bottom: number,
top: number,
near: number,
far: number
): Mat4
perspective(fov: number, aspect: number, near: number, far: number): Mat4
fov
is in radians.
lookAt(position: Vec3, target: Vec3, up: Vec3): Mat4
translate(offset: Vec3): Mat4
rotate(angle: Vec3): Mat4
scale(scale: Vec3): Mat4
transpose(): Mat4
multiplyVec4(vec: Vec4): Vec4
multiply(other: Mat4): Mat4
invert(): Mat4
Limitations
This is a GPU-based renderer. Not everything is worthwhile to express in shaders. There is a lot of edge cases in border radius, borders and hiding overflow.
This is not full HTML. Following design of
Yoga
layout engine, red-otter
only implements Flexbox layout and
makes all views position: relative
by default, which limits
what kind of layouts are possible to express (but as React Native shows,
this should not be a problem for most apps).
What is missing
Talking about roadmap, here is an unordered list of things that need a lot attention before the 1.0 release.
-
Some missing layout features:
flex-wrap
,flex-grow
,flex-shrink
,overflow: hidden
,.aspect-ratio
-
Styling:
,border-radius
,border
box-shadow
,opacity
. - Interactivity: UI controls like button, text input.
- Better text rendering: test more fonts, nested text elements, text alignment.
- Benchmarks: start measuring performance, find weak spots and fix them.
- Accessibility: it will be a hard topic to cover and I might in the end abandon it, but I have some ideas about maintaining a mirror DOM tree for screen reader support.
Testing
To write unit tests for layout, you will need a class implementing
IContext
interface. Here is basic working example:
class MockContext implements IContext {
getCanvas(): HTMLCanvasElement {
return {
clientWidth: 800,
clientHeight: 600,
} as HTMLCanvasElement;
}
line() {
// noop
}
polygon() {
// noop
}
triangles() {
// noop
}
rectangle() {
// noop
}
clear() {
// noop
}
setProjection() {
// noop
}
text() {
// noop
}
flush() {
// noop
}
loadTexture() {
return {} as WebGLTexture;
}
getFont() {
return {} as Font;
}
getWebGLContext() {
return {} as WebGL2RenderingContext;
}
}
Credits
This website was written in plain HTML and CSS with a build step script for extracting code examples from the source code.
© Tomasz Czajęcki 2023