;
+ /** Start offset of the first item in the data array */
+ readonly offset: number;
+ /** Number of items in the data (raw data length minus the offset) */
+ readonly length: number;
+
+ /**
+ * Gets the raw item at a specific index. Does **not** accept negative indices
+ *
+ * @param index Index of the item
+ * @returns The raw data object
+ */
+ readonly at: (index: number) => AnyDataEntry;
+ /**
+ * Gets a property for the item at the specified index
+ *
+ * @param index Index of the item
+ * @param property Property to read
+ * @returns The property's value
+ */
+ readonly getPropertyAt: (index: number, property: P) => Entry[P];
+ /**
+ * Gets a property for a raw data object
+ *
+ * @param obj Raw data object
+ * @param property Property to read
+ * @returns The property's value
+ */
+ readonly getPropertyFor:
(obj: AnyDataEntry, property: P) => Entry[P];
+
+ /** Raw data iterator */
+ [Symbol.iterator](): IterableIterator;
+}
+
+/** Data view constructor */
+export type DataViewConstructor = new (
+ data: AnyData,
+ keyMapping: KeyMapping,
+ offset?: number,
+) => DataView & DataViewAccessors;
+
+/**
+ * Create an accessor name for a property
+ *
+ * @param property Property name
+ * @param postfix Accessor postfix
+ * @returns An accessor name
+ */
+function createAccessorName(property: keyof Entry, postfix: AccessorPostfixes): string {
+ const trimmedProperty = String(property).replace(/\s+/g, '');
+ const capitalizedProperty = trimmedProperty.slice(0, 1).toUpperCase() + trimmedProperty.slice(1);
+ return `get${capitalizedProperty}${postfix}`;
+}
+
+/**
+ * Creates a new accessor bound to a data view
+ *
+ * @param instance Instance to bind the accessor to
+ * @param property Property to access
+ * @param postfix Accessor prostfix
+ * @returns A bound accessor function
+ */
+function createAccessor(instance: DataView, property: keyof Entry, postfix: AccessorPostfixes) {
+ const method = `getProperty${postfix}` as const;
+ return (arg: unknown) => instance[method](arg as never, property);
+}
+
+/**
+ * Creates and attaches accessors for each entry property on a data view
+ *
+ * @param instance Data view instance
+ * @param keys Entry property keys
+ */
+function attachAccessors(instance: DataView, keys: (keyof Entry)[]): void {
+ const postfixes: AccessorPostfixes[] = ['At', 'For'];
+ for (const key of keys) {
+ for (const postfix of postfixes) {
+ const name = createAccessorName(key, postfix);
+ const accessor = createAccessor(instance, key, postfix);
+ (instance as unknown as Record)[name] = accessor;
+ }
+ }
+}
+
+/**
+ * Create a new data view base class
+ *
+ * @param keys Entry property keys
+ * @returns A data view base class
+ */
+export function createDataViewClass(keys: (keyof Entry)[]): DataViewConstructor {
+ class DataViewImpl implements DataView {
+ readonly keys = keys;
+ readonly length: number;
+
+ readonly at = (index: number) => this.data[this.offset + index];
+ readonly getPropertyAt = (index: number, property: P): Entry[P] => {
+ return this.getPropertyFor(this.data[this.offset + index] ?? {}, property);
+ };
+ readonly getPropertyFor =
(obj: AnyDataEntry, property: P): Entry[P] => {
+ const key = this.keyMapping[property];
+ if (key === undefined) {
+ return undefined as Entry[P];
+ }
+
+ return (obj as Record)[key];
+ };
+
+ constructor(
+ readonly data: AnyData,
+ readonly keyMapping: KeyMapping,
+ readonly offset = 0,
+ ) {
+ this.length = data.length - offset;
+ attachAccessors(this, this.keys);
+ }
+
+ [Symbol.iterator]() {
+ const iter = this.data[Symbol.iterator]();
+ for (let index = 0; index < this.offset; index++) {
+ iter.next();
+ }
+ return iter;
+ }
+ }
+
+ return DataViewImpl as unknown as DataViewConstructor;
+}
+
+/**
+ * Loads view data from either json encoded input, a file or url,
+ * an existing data view instance, or an array of raw data
+ *
+ * @param input Raw data view input
+ * @param viewCls Data view class
+ * @returns Either a data view of the specified type or an array of raw data
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function loadViewData>(
+ input: Signal>,
+ viewCls: Type,
+): Signal {
+ const data = loadData(input, CsvFileLoaderService, {
+ papaparse: {
+ dynamicTyping: true,
+ header: false,
+ skipEmptyLines: 'greedy',
+ },
+ });
+
+ return computed(() => {
+ const result = data();
+ return result instanceof viewCls || Array.isArray(result) ? result : [];
+ });
+}
+
+/**
+ * Loads a key mapping from either json encoded input, a file or url,
+ * or an existing key mapping object
+ *
+ * @param input Raw key mapping input
+ * @param mixins Additional mappings for backwards compatability
+ * @returns A partial key mapping
+ */
+export function loadViewKeyMapping(
+ input: Signal>,
+ mixins: KeyMappingMixins = {},
+): Signal>> {
+ const data = loadData(input, JsonFileLoaderService, {});
+ return computed(() => {
+ const result = data();
+ const mapping = isRecordObject(result) ? { ...result } : {};
+
+ for (const key in mixins) {
+ if (mapping[key] === undefined && mixins[key] !== undefined) {
+ mapping[key] = mixins[key]();
+ }
+ }
+
+ for (const key in mapping) {
+ if (mapping[key] === undefined) {
+ delete mapping[key];
+ }
+ }
+
+ return mapping as Partial>;
+ });
+}
+
+/** Type with the `DATA_VIEW_OFFSET` property */
+type WithDataViewOffset = Partial>;
+/** Symbol used to "smuggle" the offset between inferViewKeyMapping and createDataView */
+const DATA_VIEW_OFFSET = Symbol('DataView offset');
+
+/**
+ * Gets the `DATA_VIEW_OFFSET` stored in a key mapping
+ *
+ * @param mapping Key mapping
+ * @returns The offset if present
+ */
+function getDataViewOffset(mapping: Partial>): number | undefined {
+ return (mapping as WithDataViewOffset)[DATA_VIEW_OFFSET];
+}
+
+/**
+ * Sets a new `DATA_VIEW_OFFSET` in a key mapping
+ *
+ * @param mapping Key mapping
+ * @param offset New offset value
+ */
+function setDataViewOffset(mapping: Partial>, offset: number): void {
+ (mapping as WithDataViewOffset)[DATA_VIEW_OFFSET] = offset;
+}
+
+/**
+ * Attempts to infer key mapping properties from raw data
+ *
+ * @param entry The first raw data entry in the data array
+ * @param mapping Mapping to update with inferred keys
+ * @param keys Expected entry property keys
+ */
+function inferViewKeyMappingImpl(entry: AnyDataEntry, mapping: Partial>, keys: (keyof T)[]): void {
+ const icase = (value: unknown) => String(value).toLowerCase();
+ const isArrayEntry = Array.isArray(entry);
+ let header: unknown[];
+
+ if (isArrayEntry) {
+ const isAllNumeric = entry.every((value) => typeof value === 'number');
+ const isBackwardsCompatibleEdges = entry.length === 7 && keys.length >= 7 && isAllNumeric;
+ if (isBackwardsCompatibleEdges) {
+ header = keys.slice(0, 7);
+ } else {
+ header = entry;
+ setDataViewOffset(mapping, 1);
+ }
+ } else {
+ header = Object.keys(entry);
+ }
+
+ for (const key of keys) {
+ const prop = mapping[key] ?? key;
+ const propICase = icase(prop);
+ const index = header.findIndex((candidate) => icase(candidate) === propICase);
+ if (index >= 0) {
+ mapping[key] = (isArrayEntry ? index : header[index]) as never;
+ } else {
+ delete mapping[key];
+ }
+ }
+}
+
+/**
+ * Validates an inferred key mapping
+ *
+ * @param mapping Inferred key mapping
+ * @param requiredKeys Required entry property keys
+ * @returns undefined if valid, otherwise an error describing the issue
+ */
+function validateViewKeyMapping(mapping: Partial>, requiredKeys: (keyof T)[]): Error | void {
+ const missingKeys: (keyof T)[] = [];
+ for (const key of requiredKeys) {
+ if (mapping[key] === undefined) {
+ missingKeys.push(key);
+ }
+ }
+
+ if (missingKeys.length > 0) {
+ return new Error(`Missing required keys: ${missingKeys.join(', ')}`);
+ }
+}
+
+/**
+ * Infers a complete key mapping from the data and a partial key mapping
+ *
+ * @param data View data
+ * @param mapping Partial existing key mapping
+ * @param requiredKeys Required property keys
+ * @param optionalKeys Optional property keys
+ * @returns A complete key mapping on success, otherwise undefined
+ */
+export function inferViewKeyMapping(
+ data: Signal | AnyData>,
+ mapping: Signal>>,
+ requiredKeys: (keyof T)[],
+ optionalKeys: (keyof T)[],
+): Signal | undefined> {
+ const errorHandler = inject(ErrorHandler);
+ const keys = [...requiredKeys, ...optionalKeys];
+ const defaultArrayKeyMapping = {} as KeyMapping;
+ keys.forEach((key, index) => (defaultArrayKeyMapping[key] = index));
+
+ return computed(() => {
+ const viewData = data();
+ if (!Array.isArray(viewData)) {
+ return viewData.keyMapping;
+ } else if (viewData.length === 0) {
+ return defaultArrayKeyMapping;
+ }
+
+ const viewMapping = { ...mapping() };
+ inferViewKeyMappingImpl(viewData[0], viewMapping, keys);
+
+ const error = validateViewKeyMapping(viewMapping, requiredKeys);
+ if (error !== undefined) {
+ errorHandler.handleError(error);
+ return undefined;
+ }
+
+ return viewMapping as KeyMapping;
+ });
+}
+
+/**
+ * Create a data view from data and key mapping
+ *
+ * @param viewCls Data view class
+ * @param data Already existing data view or array of raw data
+ * @param keyMapping Inferred key mapping for the raw data
+ * @param defaultView Default data view returned missing a data or key mapping
+ * @returns A data view of the specified class
+ */
+export function createDataView(
+ viewCls: new (data: AnyData, keyMapping: KeyMapping, offset?: number) => V,
+ data: Signal,
+ keyMapping: Signal | undefined>,
+ defaultView: V,
+): Signal {
+ return computed(() => {
+ const viewData = data();
+ if (viewData instanceof viewCls) {
+ return viewData;
+ }
+
+ const viewMapping = keyMapping();
+ if (viewMapping !== undefined) {
+ return new viewCls(viewData as AnyData, viewMapping, getDataViewOffset(viewMapping));
+ }
+
+ return defaultView;
+ });
+}
diff --git a/libs/node-dist-vis/src/lib/models/edges.ts b/libs/node-dist-vis/src/lib/models/edges.ts
new file mode 100644
index 000000000..e3f4d34da
--- /dev/null
+++ b/libs/node-dist-vis/src/lib/models/edges.ts
@@ -0,0 +1,133 @@
+import { Signal } from '@angular/core';
+import { AccessorContext } from '@deck.gl/core/typed';
+import {
+ AnyDataEntry,
+ createDataView,
+ createDataViewClass,
+ DataViewInput,
+ inferViewKeyMapping,
+ KeyMappingInput,
+ loadViewData,
+ loadViewKeyMapping,
+} from './data-view';
+
+/** Edges input */
+export type EdgesInput = DataViewInput;
+/** Edges key mapping input */
+export type EdgeKeysInput = KeyMappingInput;
+
+/** Edge entry */
+export interface EdgeEntry {
+ /** Source node index */
+ 'Cell ID': number;
+ /** Source X coordinate */
+ X1: number;
+ /** Source Y coordinate */
+ Y1: number;
+ /** Source Z coordinate */
+ Z1: number;
+ /** Target X coordinate */
+ X2: number;
+ /** Target Y coordinate */
+ Y2: number;
+ /** Target Z coordinate */
+ Z2: number;
+}
+
+/** Required edge keys */
+const REQUIRED_KEYS: (keyof EdgeEntry)[] = ['Cell ID', 'X1', 'Y1', 'Z1', 'X2', 'Y2', 'Z2'];
+/** Optional edge keys */
+const OPTIONAL_KEYS: (keyof EdgeEntry)[] = [];
+/** Base data view class for edges */
+const BaseEdgesView = createDataViewClass([...REQUIRED_KEYS, ...OPTIONAL_KEYS]);
+
+/** Edges view */
+export class EdgesView extends BaseEdgesView {
+ /**
+ * Get the source position of an edge.
+ * If an accessor context is provided the preallocated target
+ * array will be filled out and returned instead of a new array.
+ *
+ * @param index Index of data entry
+ * @param info Optional accessor context
+ * @returns The source position in format [x, y, z]
+ */
+ readonly getSourcePositionAt = (index: number, info?: AccessorContext) =>
+ this.getSourcePositionFor(this.data[index], info);
+
+ /**
+ * Get the source position of an edge.
+ * If an accessor context is provided the preallocated target
+ * array will be filled out and returned instead of a new array.
+ *
+ * @param obj Raw edge data entry
+ * @param info Optional accessor context
+ * @returns The source position in format [x, y, z]
+ */
+ readonly getSourcePositionFor = (
+ obj: AnyDataEntry,
+ info?: AccessorContext,
+ ): [number, number, number] => {
+ const position = (info?.target ?? new Array(3)) as [number, number, number];
+ position[0] = this.getX1For(obj);
+ position[1] = this.getY1For(obj);
+ position[2] = this.getZ1For(obj);
+ return position;
+ };
+
+ /**
+ * Get the target position of an edge.
+ * If an accessor context is provided the preallocated target
+ * array will be filled out and returned instead of a new array.
+ *
+ * @param index Index of data entry
+ * @param info Optional accessor context
+ * @returns The target position in format [x, y, z]
+ */
+ readonly getTargetPositionAt = (index: number, info?: AccessorContext) =>
+ this.getTargetPositionFor(this.data[index], info);
+
+ /**
+ * Get the target position of an edge.
+ * If an accessor context is provided the preallocated target
+ * array will be filled out and returned instead of a new array.
+ *
+ * @param obj Raw edge data entry
+ * @param info Optional accessor context
+ * @returns The target position in format [x, y, z]
+ */
+ readonly getTargetPositionFor = (
+ obj: AnyDataEntry,
+ info?: AccessorContext,
+ ): [number, number, number] => {
+ const position = (info?.target ?? new Array(3)) as [number, number, number];
+ position[0] = this.getX2For(obj);
+ position[1] = this.getY2For(obj);
+ position[2] = this.getZ2For(obj);
+ return position;
+ };
+}
+
+/**
+ * Load edges
+ *
+ * @param input Raw edges input
+ * @param keys Raw edges key mapping input
+ * @returns A edges view
+ */
+export function loadEdges(input: Signal, keys: Signal): Signal {
+ const data = loadViewData(input, EdgesView);
+ const mapping = loadViewKeyMapping(keys);
+ const inferred = inferViewKeyMapping(data, mapping, REQUIRED_KEYS, OPTIONAL_KEYS);
+ const emptyView = new EdgesView([], {
+ 'Cell ID': 0,
+ X1: 1,
+ Y1: 2,
+ Z1: 3,
+ X2: 4,
+ Y2: 5,
+ Z2: 6,
+ });
+
+ return createDataView(EdgesView, data, inferred, emptyView);
+}
diff --git a/libs/node-dist-vis/src/lib/models/filters.ts b/libs/node-dist-vis/src/lib/models/filters.ts
new file mode 100644
index 000000000..4d144daaa
--- /dev/null
+++ b/libs/node-dist-vis/src/lib/models/filters.ts
@@ -0,0 +1,109 @@
+import { computed, Signal } from '@angular/core';
+import { JsonFileLoaderService } from '@hra-ui/common/fs';
+import { DataInput, isRecordObject, loadData } from './utils';
+
+/** Node filter data entry */
+export type NodeFilterEntry = string | number;
+/** Node filter input */
+export type NodeFilterInput = DataInput;
+/** Node filter predicate signature */
+export type NodeFilterPredFn = (type: string, index: number) => boolean;
+
+/** Node filter */
+export interface NodeFilter {
+ /** Node types and indices to include */
+ include?: NodeFilterEntry[];
+ /** Node types and indices to exclude */
+ exclude?: NodeFilterEntry[];
+}
+
+/** Function that always return true */
+function truthy(): boolean {
+ return true;
+}
+
+/** Function that always return false */
+function falsy(): boolean {
+ return false;
+}
+
+/** Node filter view */
+export class NodeFilterView {
+ /** Predicate that tests whether a node is included in the filter */
+ readonly includes = this.selectFilterFn();
+
+ /**
+ * Get whether the filter is empty
+ *
+ * @returns Whether the filter is empty, i.e. all nodes are included
+ */
+ readonly isEmpty = () => {
+ const { include, exclude = [] } = this;
+ return include === undefined && exclude.length === 0;
+ };
+
+ /** Initialize the filter */
+ constructor(
+ readonly include: NodeFilterEntry[] | undefined,
+ readonly exclude: NodeFilterEntry[] | undefined,
+ ) {}
+
+ /**
+ * Selects a node filter predicate function based on whether
+ * parts of the filter is empty
+ *
+ * @returns A node filter predicate function
+ */
+ private selectFilterFn(): NodeFilterPredFn {
+ const { include, exclude = [] } = this;
+ const includeFn = this.createFilterFn(include);
+ const excludeFn = this.createFilterFn(exclude);
+
+ if (include === undefined) {
+ return exclude.length === 0 ? truthy : (type, index) => !excludeFn(type, index);
+ } else if (include.length === 0) {
+ return falsy;
+ } else if (exclude.length === 0) {
+ return includeFn;
+ } else {
+ return (type, index) => includeFn(type, index) && !excludeFn(type, index);
+ }
+ }
+
+ /**
+ * Create a filter predicate for some entries
+ *
+ * @param entries Filter entries
+ * @returns A filter predicate that returns true for value in the entries
+ */
+ private createFilterFn(entries: NodeFilterEntry[] | undefined): NodeFilterPredFn {
+ const entriesSet = new Set(entries);
+ return (type, index) => entriesSet.has(type) || entriesSet.has(index);
+ }
+}
+
+/**
+ * Load a node filter
+ *
+ * @param input Node filter raw input
+ * @param selection Backwards compatable node filter include array
+ * @returns A node filter view
+ */
+export function loadNodeFilter(
+ input: Signal,
+ selection: Signal>,
+): Signal {
+ const data = loadData(input, JsonFileLoaderService, {});
+ const selectionData = loadData(selection, JsonFileLoaderService, {});
+ return computed(() => {
+ const result = data();
+ if (isRecordObject(result)) {
+ const { include, exclude } = result as NodeFilter;
+ return new NodeFilterView(include, exclude);
+ }
+
+ const includeSelection = selectionData();
+ const include = Array.isArray(includeSelection) ? includeSelection : undefined;
+ return new NodeFilterView(include, undefined);
+ });
+}
diff --git a/libs/node-dist-vis/src/lib/models/nodes.ts b/libs/node-dist-vis/src/lib/models/nodes.ts
new file mode 100644
index 000000000..682a63a73
--- /dev/null
+++ b/libs/node-dist-vis/src/lib/models/nodes.ts
@@ -0,0 +1,123 @@
+import { Signal } from '@angular/core';
+import { AccessorContext } from '@deck.gl/core/typed';
+import {
+ AnyDataEntry,
+ createDataView,
+ createDataViewClass,
+ DataViewInput,
+ inferViewKeyMapping,
+ KeyMappingInput,
+ loadViewData,
+ loadViewKeyMapping,
+} from './data-view';
+
+/** Node view input */
+export type NodesInput = DataViewInput;
+/** Node view key mapping input */
+export type NodeKeysInput = KeyMappingInput;
+
+/** Node entry */
+export interface NodeEntry {
+ /** Cell type */
+ 'Cell Type': string;
+ /** Optional cell ontology id */
+ 'Cell Ontology ID'?: string;
+ /** X coordinate */
+ X: number;
+ /** Y coordinate */
+ Y: number;
+ /** Optional Z coordinate */
+ Z?: number;
+}
+
+/** Required node keys */
+const REQUIRED_KEYS: (keyof NodeEntry)[] = ['Cell Type', 'X', 'Y'];
+/** Optional node keys */
+const OPTIONAL_KEYS: (keyof NodeEntry)[] = ['Cell Ontology ID', 'Z'];
+/** Base nodes view class */
+const BaseNodesView = createDataViewClass([...REQUIRED_KEYS, ...OPTIONAL_KEYS]);
+
+/** Nodes view */
+export class NodesView extends BaseNodesView {
+ /**
+ * Get the position of a node.
+ * If an accessor context is provided the preallocated target
+ * array will be filled out and returned instead of a new array.
+ *
+ * @param index Index of data entry
+ * @param info Optional accessor context
+ * @returns The position in format [x, y, z]
+ */
+ readonly getPositionAt = (index: number, info?: AccessorContext) =>
+ this.getPositionFor(this.data[index], info);
+
+ /**
+ * Get the position of a node.
+ * If an accessor context is provided the preallocated target
+ * array will be filled out and returned instead of a new array.
+ *
+ * @param obj Raw node data entry
+ * @param info Optional accessor context
+ * @returns The position in format [x, y, z]
+ */
+ readonly getPositionFor = (obj: AnyDataEntry, info?: AccessorContext): [number, number, number] => {
+ const position = (info?.target ?? new Array(3)) as [number, number, number];
+ position[0] = this.getXFor(obj);
+ position[1] = this.getYFor(obj);
+ position[2] = this.getZFor(obj) ?? 0;
+ return position;
+ };
+
+ /**
+ * Get the dimensions (sometimes called 'extent') of all nodes
+ * across the X, Y, and Z axes
+ *
+ * @returns An array of [minimum, maximum] values
+ */
+ readonly getDimensions = (): [number, number] => {
+ if (this.dimensions) {
+ return this.dimensions;
+ }
+
+ let min = Number.MAX_VALUE;
+ let max = -Number.MAX_VALUE;
+ for (const obj of this) {
+ const x = this.getXFor(obj);
+ const y = this.getYFor(obj);
+ const z = this.getZFor(obj) ?? 0;
+ min = Math.min(min, x, y, z);
+ max = Math.max(max, x, y, z);
+ }
+
+ this.dimensions = [min, max];
+ return this.dimensions;
+ };
+
+ /** Cached dimensions */
+ private dimensions?: [number, number] = undefined;
+}
+
+/**
+ * Load nodes
+ *
+ * @param input Raw nodes input
+ * @param keys Raw nodes key mapping input
+ * @param nodeTargetKey Backwards compatable 'Cell Type' key mapping
+ * @returns A nodes view
+ */
+export function loadNodes(
+ input: Signal,
+ keys: Signal,
+ nodeTargetKey?: Signal,
+): Signal {
+ const data = loadViewData(input, NodesView);
+ const mapping = loadViewKeyMapping(keys, { 'Cell Type': nodeTargetKey });
+ const inferred = inferViewKeyMapping(data, mapping, REQUIRED_KEYS, OPTIONAL_KEYS);
+ const emptyView = new NodesView([], {
+ 'Cell Type': 0,
+ X: 1,
+ Y: 2,
+ });
+
+ return createDataView(NodesView, data, inferred, emptyView);
+}
diff --git a/libs/node-dist-vis/src/lib/models/utils.ts b/libs/node-dist-vis/src/lib/models/utils.ts
new file mode 100644
index 000000000..7b36d1f54
--- /dev/null
+++ b/libs/node-dist-vis/src/lib/models/utils.ts
@@ -0,0 +1,70 @@
+import { ErrorHandler, inject, Signal, Type } from '@angular/core';
+import { FileLoader } from '@hra-ui/common/fs';
+import { derivedAsync } from 'ngxtension/derived-async';
+import { catchError, EMPTY, filter, map } from 'rxjs';
+
+/** Accepted data input types */
+export type DataInput = T | File | URL | string | undefined;
+
+/**
+ * Tests whether a value is a plain object
+ *
+ * @param obj Object to test
+ * @returns True if `obj` is a plain object, otherwise false
+ */
+export function isRecordObject(obj: unknown): obj is Record {
+ return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
+}
+
+/**
+ * Tries to parse a value as json
+ *
+ * @param value Value to parse
+ * @returns Parsed json value if possible, otherwise the original value
+ */
+export function tryParseJson(value: unknown): unknown {
+ try {
+ if (typeof value === 'string') {
+ return JSON.parse(value);
+ }
+ } catch {
+ // Ignore errors
+ }
+
+ return value;
+}
+
+/**
+ * Loads data from either an url, file, json encoded string, or passed directly.
+ * The resulting signal value is undefined until data has has been sucessfully loaded.
+ *
+ * @param input Raw input
+ * @param loaderService Service to load urls and files
+ * @param options File loader options
+ * @returns Loaded data
+ */
+export function loadData(
+ input: Signal>,
+ loaderService: Type>,
+ options: Opts,
+): Signal {
+ const loader = inject(loaderService);
+ const errorHandler = inject(ErrorHandler);
+
+ return derivedAsync(() => {
+ const data = tryParseJson(input());
+ if (typeof data === 'string' || data instanceof File || data instanceof URL) {
+ const source = data instanceof URL ? data.toString() : data;
+ return loader.load(source, options).pipe(
+ filter((event) => event.type === 'data'),
+ map((event) => event.data),
+ catchError((error) => {
+ errorHandler.handleError(error);
+ return EMPTY;
+ }),
+ );
+ }
+
+ return data;
+ });
+}
diff --git a/libs/node-dist-vis/src/lib/models/view-mode.ts b/libs/node-dist-vis/src/lib/models/view-mode.ts
new file mode 100644
index 000000000..e3cbc68cf
--- /dev/null
+++ b/libs/node-dist-vis/src/lib/models/view-mode.ts
@@ -0,0 +1 @@
+export type ViewMode = 'explore' | 'inspect' | 'select';
diff --git a/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts
new file mode 100644
index 000000000..e7629c60f
--- /dev/null
+++ b/libs/node-dist-vis/src/lib/node-dist-vis/node-dist-vis.component.ts
@@ -0,0 +1,236 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ ElementRef,
+ ErrorHandler,
+ inject,
+ input,
+ output,
+ signal,
+ viewChild,
+} from '@angular/core';
+import { DeckProps, OrbitView, OrbitViewState, PickingInfo, View } from '@deck.gl/core/typed';
+import { createController } from '../deckgl/controller';
+import { createDeck } from '../deckgl/deck';
+import { createEdgesLayer } from '../deckgl/edges';
+import { createNodesLayer } from '../deckgl/nodes';
+import { createScaleBarLayer } from '../deckgl/scale-bar';
+import { ColorMapEntry, ColorMapView, loadColorMap } from '../models/color-map';
+import { AnyData, AnyDataEntry, KeyMapping } from '../models/data-view';
+import { EdgeKeysInput, EdgesInput, loadEdges } from '../models/edges';
+import { loadNodeFilter, NodeFilterInput } from '../models/filters';
+import { loadNodes, NodeKeysInput, NodesInput } from '../models/nodes';
+import { ViewMode } from '../models/view-mode';
+
+/** CursorState is not exported by deckgl */
+type CursorState = Parameters>[0];
+
+/** OrbitView's constructor is poorly typed */
+type OrbitViewProps = ConstructorParameters[0] &
+ ConstructorParameters>[0];
+
+/** Initial visualization deckgl state */
+const INITIAL_VIEW_STATE = {
+ version: 0,
+ orbitAxis: 'Y',
+ camera: 'orbit',
+ zoom: 9,
+ minRotationX: -90,
+ maxRotationX: 90,
+ rotationX: 0,
+ rotationOrbit: 0,
+ dragMode: 'rotate',
+ target: [0.5, 0.5],
+};
+
+/** Node distance visualization */
+@Component({
+ selector: 'hra-node-dist-vis',
+ standalone: true,
+ template: '',
+ styles: ':host { display: block; }',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class NodeDistVisComponent {
+ /** View mode of the visualization */
+ readonly mode = input('explore');
+
+ /** Node data */
+ readonly nodes = input();
+ /** Node key mapping data */
+ readonly nodeKeys = input();
+ /** Node target selector used when calculating edges */
+ readonly nodeTargetSelector = input(); // TODO default (must take nodeTargetValue into consideration, i.e. don't set default on this input)
+ /**
+ * Column/property of the node's 'Cell Type' values
+ *
+ * @deprecated Use `nodeKeys` to specify the column instead
+ */
+ readonly nodeTargetKey = input();
+ /**
+ * Node target selector used when calculating edges
+ *
+ * @deprecated Use `nodeTargetSelector` instead
+ */
+ readonly nodeTargetValue = input();
+
+ /** Edge data if already calculated */
+ readonly edges = input();
+ /** Edge key mapping data */
+ readonly edgeKeys = input();
+ /** Max distance to consider when calculating edges */
+ readonly maxEdgeDistance = input(); // TODO default + transform
+
+ /** Color map data */
+ readonly colorMap = input();
+ /** Color map key mapping data */
+ readonly colorMapKeys = input | string>();
+ /**
+ * Column/property of the color map's 'Cell Type' values
+ *
+ * @deprecated Use `colorMapKeys` to specify the column instead
+ */
+ readonly colorMapKey = input();
+ /**
+ * Column/property of the color map's 'Cell Color' values
+ *
+ * @deprecated Use `colorMapKeys` to specify the column instead
+ */
+ readonly colorMapValue = input();
+
+ /** Node filter data */
+ readonly nodeFilter = input();
+ /**
+ * Node 'Cell Type's to display
+ *
+ * @deprecated Use `nodeFilter`'s `include` property to specify included nodes instead
+ */
+ readonly selection = input();
+
+ /** Emits when the user clicks on a node */
+ readonly nodeClick = output();
+ /** Emits when the user starts or stops hovering over a node */
+ readonly nodeHover = output();
+ /** Emits when the user selects one or more nodes in the 'select' view mode */
+ readonly nodeSelectionChange = output(); // TODO fix type
+
+ /** Reference to the rendered canvas element */
+ readonly canvas = computed(() => this.canvasElementRef().nativeElement);
+ /** Reference to the deckgl instance */
+ readonly deck = createDeck(this.canvas, {
+ controller: true,
+ views: new OrbitView({ id: 'orbit', orbitAxis: 'Y' } as OrbitViewProps),
+ initialViewState: INITIAL_VIEW_STATE,
+ layers: [],
+ getCursor: this.getCursor.bind(this),
+ onClick: this.onClick.bind(this),
+ onHover: this.onHover.bind(this),
+ onViewStateChange: ({ viewState }) => this.viewState.set(viewState),
+ onError: (error) => this.errorHandler.handleError(error),
+ });
+
+ /** Canvas element wrapped inside an `ElementRef` */
+ private readonly canvasElementRef = viewChild.required>('canvas');
+ /** Error handler for the application */
+ private readonly errorHandler = inject(ErrorHandler);
+
+ /** Current version value of the deckgl view state */
+ private viewStateVersion = INITIAL_VIEW_STATE.version;
+ /** Current deckgl view state */
+ private readonly viewState = signal