Authoring IIIF stories and tours

How to encode a story as a IIIF Presentation 3 manifest that this viewer reads, including the annotation-driven tour stops it accepts.

The viewer also reads legacy storymap JSON; see the README for that format. This page covers the IIIF side. docs/storymap-as-iiif-manifest.md documents the full property mapping; this page is the task-oriented version.

The shortest possible story

One canvas, one slide:

{
    "@context": "http://iiif.io/api/presentation/3/context.json",
    "id": "https://example.org/story/1",
    "type": "Manifest",
    "label": { "en": ["A story"] },
    "items": [
        {
            "id": "https://example.org/story/1/canvas/1",
            "type": "Canvas",
            "height": 3000,
            "width": 2315,
            "label": { "en": ["The first slide"] },
            "summary": { "en": ["Its body text."] },
            "items": [
                {
                    "type": "AnnotationPage",
                    "items": [
                        {
                            "type": "Annotation",
                            "motivation": "painting",
                            "body": {
                                "id": "https://example.org/image/full/max/0/default.jpg",
                                "type": "Image",
                                "format": "image/jpeg",
                                "service": [
                                    {
                                        "id": "https://example.org/image/info.json",
                                        "type": "ImageService3",
                                        "profile": "level2"
                                    }
                                ]
                            },
                            "target": "https://example.org/story/1/canvas/1"
                        }
                    ]
                }
            ]
        }
    ]
}

One canvas is one slide: label becomes the headline, summary the body text, and the painting annotation's body the slide media. There is also one slide for a Canvas that has no painting at all.

That service array on the body is also where the basemap comes from. With storymap:basemap: "iiif" the viewer takes the first painting body carrying an ImageService3 as the image basemap and fetches its info.json, which is how iiif.url is resolved — so the basemap service is spelled the standard way and needs no mapconfig term.

Guided tours: one image, many stops

This is the Micrio/Storiiies shape and the reason the format exists here: a single canvas shown as a map, with one slide per detail. An annotation with commenting, tagging, classifying or describing and a fragment target becomes a slide, and the view fits the region the target points at.

Two things are required:

  1. The canvas must be presented as an image map — a map configuration service with storymap:basemap: "iiif" and storymap:mapAsImage: true. Region stops are honoured in image mode; a geographic map ignores them.
  2. The target must resolve to a region. An annotation aimed at the whole canvas is not a stop — the canvas slide already shows that.
"service": [
    {
        "id": "https://example.org/story/1/map-config",
        "type": "Service",
        "profile": "https://cmahnke.github.io/StoryMapJS/context.json/mapconfig",
        "storymap:basemap": "iiif",
        "storymap:mapAsImage": true
    }
]

Then the stops, on the canvas, after its painting:

{
    "type": "AnnotationPage",
    "items": [
        {
            "id": "https://example.org/story/1/canvas/1/annotation/2",
            "type": "Annotation",
            "motivation": "commenting",
            "label": { "en": ["The lower group"] },
            "body": {
                "type": "TextualBody",
                "format": "text/plain",
                "value": "First paragraph.\n\nSecond paragraph."
            },
            "target": {
                "type": "SpecificResource",
                "source": "https://example.org/story/1/canvas/1",
                "selector": {
                    "type": "FragmentSelector",
                    "value": "xywh=120,1500,1100,900"
                }
            }
        }
    ]
}

The annotation label becomes the stop's headline — that is what the slider and the map marker show. Stops render in annotation order, after the canvas slide.

Target selectors

Selector Becomes
FragmentSelector / ImageApiSelector with xywh=x,y,w,h that image-pixel region. xywh=pixel:… works too. A negative origin is clamped to the canvas (targets routinely overshoot it); a region fully outside it is dropped
PointSelector (x, y) a square of 5% of the canvas's smaller side, centred on the point and clamped to the canvas — a pin you can fit
TextQuoteSelector (exact, optional prefix/suffix) preserved; not resolved, because a canvas carries no transcript. Use it to anchor a stop to quoted text in a tool that has one
TimeState (start, end) or a start/end range preserved on the stop; a time-ranged narration can be built from it
SvgSelector preserved on the stop; not resolved and not fitted — an annotation with only an SvgSelector (no region) is not a stop, because there is no region to fit

One level of refinedBy is followed, so a fragment refined by a quote works.

Bodies

A stop's body may be one resource or an array.

Locations, groups and attribution

Annotations in a separate file

A Canvas can point at an AnnotationCollection with seeAlso, and the viewer follows it. Nothing is fetched while the story loads — that would put a network round trip in front of the first paint — so the stops on the canvas appear immediately, and a host asks for the rest afterwards:

await sm.loadAnnotations();
sm.on("annotationsloaded", (result) => {
    console.log(`added ${result.stops.length} stops`);
});

One level is followed: a referenced AnnotationPage is read, but a seeAlso inside that page is not, and a SearchService1 is recorded rather than followed. Stops from a referenced page are appended after the canvases already rendered, not placed after the canvas they annotate, because that canvas has been visited. A uniqueid is required, so a stop can be deep-linked.

What this viewer does not read yet

Checking your work

npm run validate:iiif   # the official IIIF validator, over every example

public/examples-iiif/annotated-image.json is a complete four-stop tour using everything above, and is what e2e/annotations.spec.ts renders.