Custom Card Layouts
Create completely custom article card layouts alongside the built-in portrait and landscape layouts, giving you full control when the standard styling options aren’t enough to achieve the design you need.
Register before creating timelines
Timeline.registerCardLayout(definition) registers a layout globally for the current JavaScript
environment. Register during application startup, before constructing a timeline, so its style defaults are
copied into each timeline's defaults.
import { Timeline } from 'histropediajs';
// With the UMD script build, use the global Histropedia.Timeline instead.
Timeline.registerCardLayout({
name: 'compact',
draw(ctx) {
const style = this.getCurrentStyle();
const { left, top } = this.position;
ctx.save();
ctx.fillStyle = style.backgroundColor;
ctx.fillRect(left, top, style.width, style.height);
ctx.fillStyle = style.header.text.color;
ctx.font = style.header.text.font;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(this.title, left + 10, top + style.height / 2, style.width - 20);
ctx.restore();
},
defaultStyle: {
width: 220,
height: 48,
backgroundColor: '#fff',
header: { text: { color: '#0f172a', font: '600 14px sans-serif' } }
},
defaultHoverStyle: { backgroundColor: '#f8fafc' },
defaultActiveStyle: { backgroundColor: '#e0f2fe' }
});
const timeline = new Timeline(container, {
article: {
defaultCardLayout: 'compact',
// Disable the built-in responsive override for this minimal example.
cardLayoutBreakpoints: []
}
});
The instance wrapper performs the same global operation:
timeline.registerCardLayout(definition);
const layout = timeline.getCardLayout('compact');
// Equivalent registry access: Timeline.getCardLayout('compact')
Definition and drawing contract
Timeline.registerCardLayout(definition)
Global registrationRegister or replace a case-sensitive layout name. There is no public unregister operation.
Definition fields
name– required non-empty registry key.draw(ctx)– required renderer, called with the currentArticleasthis.getWidth()andgetHeight()– optional CSS-pixel measurements; each defaults to the current style value.getIconBox()– optional rectangular star hover/click area; defaults tofalse.defaultStyle,defaultHoverStyle, anddefaultActiveStyle– optional registered style defaults.aliasOf– alternative to drawing hooks when this name should resolve to an existing concrete layout.
Timeline.getCardLayout(name)
Global lookupReturn the registered concrete layout, follow one alias, or warn and return null when the name is unknown.
if (!Timeline.getCardLayout('compact')) {
throw new Error('Register the compact layout before constructing timelines');
}
The canvas context is shared with the axis, connectors, lanes, charts, time bands, and other cards. Isolate every
canvas property your hook changes. Wrapping the drawing code in ctx.save()/ctx.restore()
is the simplest reliable pattern; otherwise, restore each changed property yourself. Never clear the shared
canvas or leave a transform, clipping region, shadow, or compositing mode active.
Useful public article state includes id, title, subtitle, data,
position.left/top, style, hoverStyle, activeStyle,
activeHoverStyle, getCurrentStyle(), isActive, isMouseover, and opacity. Use normal
functions for hooks so this remains the current article.
Read this.data for the article's latest source data, including custom properties supplied when the
article was loaded or added through later data updates.
Call this.getCurrentStyle() inside a layout hook to retrieve the fully merged style for the
article's current normal, hover, active, or active-hover state. The individual style fields remain available
when you need to inspect a specific state.
getWidth(), getHeight(), and the hit area
Draw from this.position.left/top. Together, getWidth() and getHeight()
define the rectangular pointer hit area used for clicking and dragging. getWidth() also controls
horizontal overlap detection and row assignment, while getHeight() supplies the default connector
endpoint. There is no custom card-shape hit-test hook.
Measured height does not determine the vertical distance between stacked rows. That separation comes from
article.autoStacking.rowSpacing, so increase rowSpacing when a custom card is taller than
the configured spacing. Card visibility is determined by dates, filtering, and lane/group state, not by the
measured card dimensions. Keep measurement and drawing geometry identical and return finite, non-negative
CSS-pixel values.
Both hooks are optional. When omitted, HistropediaJS returns the current merged style's width and
height. Add explicit hooks when size is derived from text, images, article data, or other state.
Measurement hooks receive no canvas argument; use this.owner.canvasContext when text metrics are needed.
getWidth() {
const style = this.getCurrentStyle();
const key = `width:${this.title}:${style.header.text.font}`;
return this.memo(key, () => {
const ctx = this.owner.canvasContext;
ctx.save();
ctx.font = style.header.text.font;
const width = ctx.measureText(this.title).width + 24;
ctx.restore();
return Math.min(320, Math.max(120, width));
});
},
getHeight() {
const style = this.getCurrentStyle();
const imageHeight = this.imageLoaded && this.image ? 56 : 0;
return style.height + imageHeight;
},
draw(ctx) {
const width = this.getWidth();
const height = this.getHeight();
const { left, top } = this.position;
ctx.save();
ctx.fillStyle = this.getCurrentStyle().backgroundColor;
ctx.fillRect(left, top, width, height);
if (this.imageLoaded && this.image) {
ctx.drawImage(this.image, left + 8, top + this.getCurrentStyle().height + 8, 48, 48);
}
ctx.restore();
}
Only draw an image after checking this.imageLoaded && this.image. Images still use the normal
sanitizer, loader, cache, CORS, and decode settings. Include every value that affects a memoized measurement in
its key. Calling this.getWidth() or this.getHeight() from draw uses the engine's
cached result, keeping drawing, horizontal overlap detection, connector placement, and pointer interaction
aligned.
Star interaction with getIconBox()
getIconBox() defines the rectangular hover/click area for the star interaction. HistropediaJS uses
only the returned left, top, width, and height properties for
pointer interaction. Drawing the icon remains the custom renderer's responsibility; calculate any centre points
or radii needed for drawing from that rectangle.
Return false when stars are disabled or the layout does not provide a star interaction. Omitting the
hook supplies an implementation that returns false. Because the hook may be called during pointer
handling, keep it fast, deterministic, and free of drawing side effects. Keep the returned icon box inside the
card rectangle from getWidth()/getHeight(): HistropediaJS identifies the card first, then
checks its icon box.
interface CardLayoutIconBox {
left: number;
top: number;
width: number;
height: number;
}
getIconBox() {
if (this.owner.options.article.star.visible === false) return false;
const width = 18;
const height = 18;
const left = this.position.left + this.getWidth() - width - 8;
const top = this.position.top + 8;
return { left, top, width, height };
},
draw(ctx) {
const icon = this.getIconBox();
if (icon && (this.isActive || this.isStarred)) {
const centreX = icon.left + icon.width / 2;
const centreY = icon.top + icon.height / 2;
ctx.save();
ctx.fillStyle = this.isStarred ? '#f59e0b' : '#2563eb';
ctx.font = '18px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.isStarred ? '★' : '☆', centreX, centreY);
ctx.restore();
}
}
Returning the rectangle does not draw anything. The layout must draw a matching icon, normally when the article
is active or starred. A geometric star renderer can derive outerRadius as
Math.min(icon.width, icon.height) / 2 and innerRadius as
outerRadius / 2. A click inside the box toggles this.isStarred.
How custom layout defaults enter the style cascade
This is the general Article Style cascade used by built-in and custom card layouts. Registering a custom layout does not create a separate precedence system; its registered styles become the card layout defaults in the second layer:
Histropedia defaults → card layout defaults → timeline → lane → article
Each scope is deep-merged over the previous one, so it can change a single nested property without repeating
the rest. Within the timeline and lane scopes, the general style is applied first and the matching
layout-specific layoutStyles[name] values are applied second. The full order for a normal style is:
- Histropedia's base article style;
- the selected card layout's registered
defaultStyle; - timeline
article.defaultStyle; - timeline
article.layoutStyles[name].style; - lane
article.defaultStyle, when the article is in a configured lane; - lane
article.layoutStyles[name].style; - the individual article's
style.
Hover and active overrides follow the same cascade through defaultHoverStyle/hoverStyle
and defaultActiveStyle/activeStyle, then merge over the resolved normal style. When an
article is both active and hovered, hover is applied first and active is applied afterwards, so active wins only
where both states set the same property.
const timeline = new Timeline(container, {
article: {
layoutStyles: {
compact: {
style: { width: 260, backgroundColor: '#fff7ed' },
hoverStyle: { backgroundColor: '#ffedd5' },
activeStyle: { border: { color: '#ea580c', width: 2 } }
}
}
}
});
A lane override uses the same shape under that lane's article options:
const timeline = new Timeline(container, {
article: { cardLayoutBreakpoints: [] },
lane: {
data: [{
id: 'people',
article: {
defaultCardLayout: 'compact',
layoutStyles: {
compact: {
style: { backgroundColor: '#f5f3ff' },
activeStyle: { border: { color: '#7c3aed' } }
}
}
}
}]
}
});
A late registration can draw in an existing timeline, but that timeline does not automatically receive the
newly registered style defaults. Register first, reconstruct the timeline, or explicitly add matching
article.layoutStyles before selecting the late layout.
Custom style properties
A custom layout can define and read style properties that are not part of HistropediaJS's built-in
ArticleStyle model. Namespace them under an application- or layout-specific object to reduce the
chance of colliding with future library properties:
Timeline.registerCardLayout({
name: 'compact',
draw(ctx) {
const style = this.getCurrentStyle();
const compact = style.custom.compact;
ctx.save();
ctx.fillStyle = compact.accentColor;
ctx.fillRect(
this.position.left,
this.position.top,
compact.accentWidth,
this.getHeight()
);
ctx.restore();
},
defaultStyle: {
width: 220,
height: 48,
custom: {
compact: {
accentColor: '#2563eb',
accentWidth: 4
}
}
}
});
const timeline = new Timeline(container, {
article: {
layoutStyles: {
compact: {
style: {
custom: {
compact: {
accentColor: '#ea580c'
}
}
}
}
}
}
});
Custom properties participate in the same deep merging and state-style precedence as standard properties.
Supply them through registered defaults, timeline or lane article.layoutStyles, and per-article
styles. They have no built-in meaning and are read only by your renderer. Include every custom property that
affects getWidth() or getHeight() in the relevant memoization key.
TypeScript: this is an advanced JavaScript capability. The exported ArticleStyle
type declares only library-supported properties, so arbitrary custom keys require an application-defined
extended style type or a narrow type assertion.
Responsive and per-article selection
Selection precedence is: explicit article cardLayout; first matching timeline
article.cardLayoutBreakpoints rule; lane article.defaultCardLayout; timeline
article.defaultCardLayout; then the built-in portrait fallback when the resolved default is empty.
const timeline = new Timeline(container, {
article: {
defaultCardLayout: 'portrait',
cardLayoutBreakpoints: [
{ maxHeight: 180, layout: 'compact' },
{ maxHeight: 300, layout: 'landscape' }
]
}
});
timeline.load([{ id: 'ada', title: 'Ada Lovelace', from: { year: 1815 }, cardLayout: 'compact' }]);
timeline.getArticleById('ada')?.setCardLayout('compact');
Put narrower breakpoint thresholds first. Register every selected name before it can render. An unknown layout
name warns during lookup and cannot be selected with article.setCardLayout(name); use
Timeline.getCardLayout(name) for an early assertion.
Aliases and built-in names
An alias gives an existing concrete layout another selectable name. Alias definitions contain no drawing hooks or style defaults, and aliases resolve one hop only.
Timeline.registerCardLayout({
name: 'editorial-card',
aliasOf: 'compact'
});
const timeline = new Timeline(container, {
article: {
defaultCardLayout: 'editorial-card',
layoutStyles: {
'editorial-card': {
style: { backgroundColor: '#fdfcf8' }
}
}
}
});
The target must be a directly registered concrete layout. Alias-specific styles belong under the selected alias
name. HistropediaJS registers portrait and landscape as built-ins, plus
default as an alias of portrait.
Caching and runtime updates
article.memo(key, factory) stores derived values in a cache bucket dedicated to the selected layout.
Include every input that changes the result in the key:
const lines = this.memo(
`lines:${this.title}:${font}:${maxWidth}`,
() => calculateWrappedLines(this.title, font, maxWidth)
);
Prefer public setters over direct runtime-field mutation. setStyle(), setOption(), and
setCardLayout() invalidate layout caches. setHoverStyle() and
setActiveStyle() request a redraw but do not invalidate those caches. If either setter changes a
value used by getWidth(), getHeight(), or a memoized calculation, invalidate that article
explicitly. Do the same after changing any external state used by a measurement:
article.setHoverStyle({ width: 240 });
article.invalidateCaches();
timeline.requestRedraw();
Changing a memo key does not by itself invalidate the engine's cached card size. Call
invalidateCaches() before requesting a redraw whenever a measurement input changes outside a setter
that already performs invalidation. Keeping card dimensions the same across normal, hover, and active states also
avoids hit-area and stacking changes during pointer interaction.
TypeScript definition
The package exports CardLayoutDefinition, RegisteredCardLayout,
CardLayoutIconBox, CardLayoutName, and Article.
import {
Timeline,
type Article,
type CardLayoutDefinition,
type CardLayoutIconBox
} from 'histropediajs';
const compactLayout: CardLayoutDefinition = {
name: 'compact',
getWidth(this: Article): number {
return this.getCurrentStyle().width ?? 220;
},
getHeight(this: Article): number {
return this.getCurrentStyle().height ?? 48;
},
getIconBox(this: Article): CardLayoutIconBox | false {
return false;
},
draw(this: Article, ctx: CanvasRenderingContext2D): void {
const style = this.getCurrentStyle();
ctx.save();
ctx.fillStyle = style.backgroundColor ?? '#fff';
ctx.fillRect(this.position.left, this.position.top, this.getWidth(), this.getHeight());
ctx.restore();
},
defaultStyle: { width: 220, height: 48, backgroundColor: '#fff' }
};
Timeline.registerCardLayout(compactLayout);
CardLayoutName preserves autocomplete for the built-ins while accepting custom strings.
Troubleshooting and implementation checklist
- Layout not found: Register the layout before selecting it and check the exact case-sensitive name with
Timeline.getCardLayout(name). Unknown names are not automatically changed to a built-in layout. - Registered style defaults missing: A layout registered after a timeline was created is available to its renderer registry, but its
defaultStyle,defaultHoverStyle, anddefaultActiveStylewere not copied into that existing timeline. Create the timeline after registration, create a new timeline instance, or provide equivalent values throughtimeline.setOption({ article: { layoutStyles: … } }). - Visible and clickable areas differ: Draw from
this.position.left/topand make the rendered card use the same dimensions returned bygetWidth()andgetHeight(). Remember that a canvas stroke is centred on its boundary and may extend beyond the measured rectangle unless it is inset. - Cards overlap: Return finite, non-negative dimensions.
getWidth()controls horizontal overlap and row assignment, while vertical separation is controlled byarticle.autoStacking.rowSpacing. IncreaserowSpacingwhen cards are taller than the configured spacing, and invalidate caches whenever an external sizing input changes. - Hover or active styles are ignored: Read the current merged state with
this.getCurrentStyle()instead of always readingthis.style. Confirm that hover and active overrides are defined under the selected layout name. - Canvas state affects other drawing: The canvas context is shared between renderers. Set every canvas property your renderer relies on and restore any transforms, clipping, shadows, compositing, line-dash settings, or other state it changes. Wrapping the renderer in
ctx.save()andctx.restore()is the simplest way to guarantee isolation. - Star is invisible or unclickable: Drawing and interaction are separate responsibilities. Draw the icon and return its matching rectangular
getIconBox()area, or returnfalsewhen the layout does not support or display a star.
Test normal, hovered, active, active-hovered, dragged, starred, image-loading, responsive, and lane-specific states.
setStyle(...), setOption(...), and
setCardLayout(...) invalidate layout caches. After a measurement-affecting
setHoverStyle(...) or setActiveStyle(...) update, explicitly call
article.invalidateCaches() and request a redraw; do the same when external state affects measurements.
Need More Help?
If you can't find what you're looking for, browse the examples or contact us.