Custom CardLayouts

Take control of how article cards are measured and drawn while retaining timeline layout, interaction, state styling, and responsive selection.

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.

JavaScript
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 registration

Register 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 current Article as this.
  • getWidth() and getHeight() – optional CSS-pixel measurements; each defaults to the current style value.
  • getIconBox() – optional rectangular star hover/click area; defaults to false.
  • defaultStyle, defaultHoverStyle, and defaultActiveStyle – optional registered style defaults.
  • aliasOf – alternative to drawing hooks when this name should resolve to an existing concrete layout.

Timeline.getCardLayout(name)

Global lookup

Return 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:

  1. Histropedia's base article style;
  2. the selected card layout's registered defaultStyle;
  3. timeline article.defaultStyle;
  4. timeline article.layoutStyles[name].style;
  5. lane article.defaultStyle, when the article is in a configured lane;
  6. lane article.layoutStyles[name].style;
  7. 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, and defaultActiveStyle were not copied into that existing timeline. Create the timeline after registration, create a new timeline instance, or provide equivalent values through timeline.setOption({ article: { layoutStyles: … } }).
  • Visible and clickable areas differ: Draw from this.position.left/top and make the rendered card use the same dimensions returned by getWidth() and getHeight(). 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 by article.autoStacking.rowSpacing. Increase rowSpacing when 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 reading this.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() and ctx.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 return false when the layout does not support or display a star.

Test normal, hovered, active, active-hovered, dragged, starred, image-loading, responsive, and lane-specific states.

Runtime updates. Prefer public setters over direct field mutation. 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.