This file provides guidance to AI agents when working with code in this repository.
DWV (DICOM Web Viewer) is a zero-footprint, pure-JavaScript/HTML5 medical image viewer library. It parses and renders DICOM (and some non-DICOM) data and provides tools for manipulation (scroll, window/level, zoom, pan, MPR, annotation, filters). Not certified for diagnostic use. Licensed GPL-3.0.
Package manager is yarn (yarn v4, packageManager pinned in package.json).
corepack enable is enough to get the right yarn version; npm also works for
all scripts. Node >= 14 required (CI uses Node 26). Module type is "module"
(ESM throughout src/).
yarn install— install dependencies.yarn start— webpack-dev-server (config/webpack.dev.js), opens example pages, including thedev/pacs/viewer.htmltest viewer.yarn test— run tests with vitest in watch mode.yarn test-ci— one-shot vitest run with coverage (v8 provider, thresholds 50% statements/branches/functions/lines); this is what CI runs.- Run a single test file:
yarn vitest run tests/image/image.test.js - Run tests matching a name:
yarn vitest run -t "some test name" yarn lint— eslint oversrc/**/*.js,tests/**/*.js,*.jsusingconfig/eslint.config-full.js(a superset of the rooteslint.config.js, adds jsdoc rules).yarn build—pack(webpack prod bundle) +types(tsc-generated.d.tsviaresources/api/tsconfig.json) +api(api-extractor, checked againstresources/api/dwv.api.md).yarn build-all—build+pack-node(Node-targeted bundle,config/webpack.node.js).yarn build-demo— builds the demo pages (config/webpack.demo.js), used by CI to publish togh-pages.yarn doc— generates jsdoc site (resources/doc/jsdoc.conf.json).
Tests live in tests/<area>/*.test.js mirroring src/<area>/, using vitest
with environment: 'node' (jsdom pulled in as needed) and DICOM/zip/DICOMDIR
fixtures under tests/data.
.github/workflows/nodejs-ci.yml runs on every push, pull request and
merge-group, on Node 26 with yarn through corepack. A change passes when
these steps all succeed, in this order:
yarn install --immutable—yarn.lockmust already matchpackage.json; commit the lockfile with any dependency change.yarn lint— no eslint errors. The jsdoc plugin's recommended rules (missing jsdoc, params, returns...) are only warnings and don't fail the step (there is no--max-warnings); the errors are the baseeslint.config.jsrules plusjsdoc/tag-lines(one blank line before the tags) andjsdoc/require-description-complete-sentence. Keep warnings at zero anyway.yarn test-ci— all vitest tests pass and coverage stays at or above 50% for statements, branches, functions and lines (vitest.config.js).yarn build— the webpack bundle, thetsctype generation (so the jsdoc types must type-check) and api-extractor must all succeed. Theapistep runs with--local: an API change rewritesresources/api/dwv.api.mdinstead of failing, and that file is only committed at release time.
On pushes to develop or master CI also runs yarn build-demo and
publishes the demo to gh-pages (demo/trunk or demo/stable). To
reproduce CI locally, run yarn lint && yarn test-ci && yarn build.
User stories/requirements (DWV-REQ-<GROUP>-<NN>-<NNN>) are defined in
resources/doc/user-stories.json ({id, name, group, description}) — this
JSON is the source of truth, not
resources/doc/tutorials/user-stories.md, which is a generated file (see
below) and gets overwritten.
Tests opt into traceability by suffixing their name with
- #<id> <name>, e.g. in tests/command/undoStack.test.js:
test('UndoStack - #DWV-REQ-UI-08-002 Draw action undo/redo', ...). Both the
id and the name after # must match a user-stories.json entry verbatim
(exact string equality) or the reference is reported as unresolved. Not
every test needs a requirement reference — only ones demonstrating a
specific user story.
The link is materialized by a separate sibling tool,
jsonqa2md (cloned alongside this
repo, not vendored in src//node_modules), run manually as part of
resources/scripts/prep-release.sh step 5:
yarn test-ci(vitest--reporter json) writesbuild/test-results.json.node ../jsonqa2md/jsonqa2mdreads that plususer-stories.jsonand regenerates bothresources/doc/tutorials/user-stories.md(with⚠️warnings for duplicate ids/names within a group) andresources/doc/tutorials/test-results.md(per-test pass/fail plus a traceability matrix of which requirements have tests, and whether they pass).
When adding a new requirement or wiring a test to one: edit
resources/doc/user-stories.json, not the .md files.
Library entry point src/index.js re-exports each subsystem's own
index.js (app, command, dicom, gui, image, math, tools,
utils). Everything uses native EventTarget/CustomEvent for
communication — controllers re-listen to their children's events and
re-dispatch them upward, so most state changes eventually surface as an
event on the top-level App.
App (application.js) is the public facade and central event bus, but it
does not do the work itself: App.init() creates and wires five
controllers, and most of App's own methods are now thin,
deprecated-since-v0.37 forwarding wrappers — the controllers, reachable
via app.getLoadController()/getDataController()/etc., are the current
recommended API.
LoadController— picksFilesLoader/UrlsLoader/MemoryLoaderbased on input type, re-dispatches their load events tagged with{dataid, isfirstitem}(isfirstitemonloaditemonly). The formerloadtypefield was removed in v0.37 along with legacy.jsonstate loading.DataController— owns#dataList: Record<dataId, DicomData>. Its#setDataContentinspects Modality/pixel-data presence to route parsed data toImageFactory,MaskFactory(ModalitySEG),AnnotationGroupFactory(ModalitySR), orRtStructFactory(ModalityRTSTRUCT, rasterizes contours against an already-loaded reference series). Also handles multi-volume/4D data viaDicomSliceDataList, buffering same-origin slices until load completes (see Multi-volume / 4D data below).StageController— owns the singleStage(allLayerGroups/layers). CreatesViews viaViewFactoryat render time (oneViewper layer/layer-group, even for a sharedImage), buildsViewLayer/DrawLayers, and keeps each storedViewConfigin sync with user-driven wl/opacity/ colourmap changes.ToolboxController— holds{name: toolInstance}, tracks the selected tool, binds a layer group's pointer/keyboard events to the active tool.UndoController— thin wrapper aroundUndoStack(src/command/undoStack.js).ViewController(per view layer, wraps oneView+PlaneHelper) andDrawController(per draw layer, wraps oneAnnotationGroup, routes edits through undoable commands) are created insideViewLayer/DrawLayerrespectively, not byAppdirectly.
- IO loaders (
src/io/):LoaderBase(extendsLoadHandlers, the on*-callback contract) is implemented byDicomDataLoader,ZipLoader,MultipartLoader(WADO-RS multipart/related),RawImageLoader/RawVideoLoader(non-DICOM). Loader capability is queried viacanLoadFile/canLoadUrl/canLoadMemory(extension/media-type based).getLoaderList()(loaderList.js) is a lazily-built singleton list to avoid a circular-import (MultipartLoader→MemoryLoader→loaderList→MultipartLoader). The orchestratorsFilesLoader/UrlsLoader/MemoryLoaderpick the first matching loader, read the raw bytes (FileReader/XMLHttpRequest), and callloader.load(). DWV is a DICOMweb User Agent only (WADO-URI, WADO-RS, STOW-RS) — QIDO-RS only exists in thedev/pacsdemo, not coresrc/— and has no DIMSE networking (no C-STORE/C-FIND/C-MOVE/PDU/Association handling); it's HTTP/DICOMweb only. - DICOM parsing (
src/dicom/):DicomParser.parse()reads the preamble, File Meta group (incl.TransferSyntaxUID), then the dataset viaDataReader(endian/VR-aware), usingdictionary.jsfor VR/transfer-syntax tables, producingRecord<tagKey, DataElement>.getSyntaxDecompressionName(syntax)maps the transfer syntax to one of'jpeg2000'/'jpeg-baseline'/'jpeg-lossless'/'rle'(or undefined if uncompressed). Private tags (not in the dictionary) read from implicit VR data come out as VRUNwith raw byte values; callers decode them as needed (e.g.getPrivateTagValueindicomVolume.js).DicomWriter(dicomWriter.js) writes data back using per-tag writing rules;setRemovePrivateTags(true)drops all odd-group tags (including inside sequences) and overrides the rules. - Pixel decompression (
src/image/decoder.js,src/decoders/): when a decompression algo is needed,DicomBufferToData(src/image/ dicomBufferToData.js, called byDicomDataLoader) builds aPixelBufferDecoderwhich dispatches to aThreadPool(src/utils/thread.js) of web workers —decoders/pdfjs/(vendored Mozilla pdf.js, patched for 16-bit signed grayscale) handles JPEG Baseline/JPEG2000,decoders/rii-mango/(vendored rii-mango/JPEGLosslessDecoderJS) handles JPEG Lossless,decoders/dwv/is dwv's own RLE decoder. The sameThreadPool/WorkerTaskmachinery is reused for image labeling (image/labelingThread.js, e.g. flood fill) and resampling (image/resamplingThread.js). - Image/AnnotationGroup construction (
src/image/*Factory.js, invoked fromDataController):ImageFactorybuilds anImage(pixel buffer +Geometry/Size/Spacing, rescale slope/intercept viarsi.js, SUV factor for PET, per-frame functional groups);MaskFactorybuilds aMaskImage(a segmentation-specific subclass ofImage, see below) from DICOM SEG;AnnotationGroupFactoryparses DICOM SR into anAnnotationGroupofAnnotations;RtStructFactoryrasterizes RTSTRUCT contours into aMaskImage. The exporteddemoCreateImage/demoCreateMaskImage/demoCreateViewhelpers (formerlycreate*) are single-file shortcuts for demos only, not the app path. Images are built progressively: the first load item creates theImage, later items (slices, or frames of a multi-frame file) are added viaappendSlice/appendFrameBufferinto a buffer preallocated frommeta.sliceCapacity(load items × frames per item). Don't confuse it withnumberOfItems(loader option andDicomDataproperty: how many files/urls/buffers/multipart parts are in the load);ImageFactory.create()is where the latter becomes the former. - View construction and rendering (
src/image/view*.js,src/gui/, at render time viaStageController):ViewFactorywraps anImagein aView(orientation/position,WindowLut, colour map, window presets — DICOM-provided, computed'minmax', or app-supplied viacustom.wlPresets).View.generateImageData()dispatches onimage.getPhotometricInterpretation()to one of the pure pixel-fill functionsgenerateImageDataMonochrome/Rgb/YbrFull/PaletteColor(each in its ownview*.jsfile), which useWindowLut(=ModalityLutVoiLut, fromrsi.js/voiLut.js) and aColourMap(luts.js) to produce anImageData.ViewLayer(src/gui/viewLayer.js) draws that to a plain HTML5<canvas>(2D context) — no Konva involved.
Stage (src/gui/stage.js) → multiple LayerGroups (layerGroup.js) →
each owns ViewLayers (canvas pixel rendering), DrawLayers (Konva-based
vector annotation rendering, via DrawController), and an optional
InfoLayer (overlay text, infoData.js). binders.js synchronises
zoom/pan/window-level/etc. across multiple linked layer groups (e.g.
axial/coronal/sagittal MPR views).
Every concrete tool (WindowLevel, Scroll, ZoomAndPan, Opacity, Draw,
Brush, Filter, Floodfill, Livewire — registered in toolList.js)
extends LayerGroupPointer, which normalises pointer/touch input into a
drag lifecycle and is composed (not subclassed) from pluggable behaviors
under src/tools/behaviors/ (dragBehavior, wheelBehavior,
hoverBehavior, doubleClickBehavior, tapBehavior, twoTouchBehavior,
plus draw/brush/floodfill/livewire-specific variants). src/tools/shapes/
holds the geometric shape classes and Konva renderers used by Draw/Brush
(rectangle, ellipse, circle, arrow, ruler, protractor, ROI, bidimensional,
plus label/anchor/editor helpers).
Tools never mutate state directly — they create Command objects
(src/command/: AddAnnotationCommand/RemoveAnnotationCommand/
UpdateAnnotationCommand, DrawBrushCommand, RunFilterCommand,
DeleteSegmentCommand, ChangeSegmentColourCommand) and push them through
UndoController.addToUndoStack(), so all edits are undoable via
UndoStack (src/command/undoStack.js).
Two parallel models, both stored alongside regular pixel data on DicomData:
- Masks (DICOM SEG/RTSTRUCT →
DicomData.image): one label per voxel in aMaskImage(src/image/maskImage.js, extendsImage, split out from it to keep segment-aware behavior — per-segment/per-slice bookkeeping, brush offset-editing, per-segment volume/centroid/diameter labeling — off the base pixel class), with a segment list (number/colour/name) managed byMaskSegmentHelper(src/image/maskSegmentHelper.js, deliberately does not touch pixels itself — pixel edits go through undoable commands).MaskImageowns aSegmentCollection(per-segment ROI slice buffers) and anImageContourfor outline-style rendering. - Annotations (DICOM SR, or drawing tools →
DicomData.annotationGroup): vector/graphicAnnotationobjects grouped in anAnnotationGroup, rendered byDrawLayer(Konva).
The tag that discriminates volumes is not known until all data is loaded, so
guessVolumeIndices tries an ordered list of volume-id getters
(defaultVolumeIdCandidates: TemporalPositionIdentifier,
TemporalPositionIndex, DiffusionBValue (incl. private b-value tags via
defaultPrivateBValueRules, VR UN values decoded as ASCII), …,
AcquisitionTime last) and keeps the first
that yields a valid, consistent per-volume grouping. Candidates flagged
preLoad are also used while loading (getVolumeIdTagValue) and must not
vary within a volume. The same logic serves both a series of files
(DicomSliceDataList in dataController.js) and the frames of a single
multi-frame file (getSortedFramesGeometry in dicomGeometry.js, used by
ImageFactory).
The JSON app-state (src/io/state.js, JSONTextLoader,
App.applyJsonState(), App.setDrawings(), konvaToAnnotation) was
removed in v0.37; .json inputs are no longer loadable. Use the
Annotation/AnnotationGroup/DICOM SR path.
src/app/custom.js exports a single mutable custom object (window/level
presets per modality, shape label texts, private b-value rules, volume-id
candidates, pixel-unit getter, ROI dialog override) meant to be overridden by
embedding applications — check it before adding new hardcoded
modality-specific behavior. custom.volumeIdCandidates and
custom.privateBValueRules replace the defaults; extend the exported
defaultVolumeIdCandidates/defaultPrivateBValueRules (from dicom/index.js,
typedefs in volumeTypes.js) rather than rewriting them.
custom.getVolumeIdTagValue/getPostLoadVolumeIdTagValue are deprecated
since v0.37.
logger.js— global mutableloggersingleton (TRACE..ERROR, defaultWARN), used pervasively including to flag deprecatedAppcalls.thread.js— sharedThreadPool/WorkerTask/WorkerThreadweb-worker infrastructure (see pixel decoding, labeling, resampling above).listen.js— a manualListenerHandler(non-EventTarget) used only bygui/viewLayer.jsandgui/drawLayer.js, distinct from theEventTarget/CustomEventpattern used everywhere else.i18n.js— minimal translation namespace (currently unit symbols), designed for override by the embedding app.