Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | import type {
InstanceGroup,
NaturalizedInstance,
RuleContext,
SplitRule,
} from './types';
/**
* Builds the bucket key an instance is grouped under for a given rule.
*
* The key is **namespaced by the rule** (via `ruleDiscriminator`) so two
* different rules can never share a bucket even if their split values coincide,
* and it is **JSON-encoded** so the parts can't collide through a separator -
* e.g. an `&` inside a tag value, or `undefined` vs `''` vs a missing tag, which
* a plain string join would alias together.
*/
function buildSplitKey(
instance: NaturalizedInstance,
context: RuleContext,
splitRule: SplitRule,
ruleDiscriminator: string | number
): string {
const groupBy = splitRule.groupBy ?? ['SeriesInstanceUID'];
const parts = groupBy.map((key) =>
typeof key === 'function' ? key(instance, context) : instance[key]
);
return JSON.stringify([ruleDiscriminator, ...parts]);
}
/**
* Groups instances into instance groups using the first matching split rule per
* instance (rules are evaluated in order; first match wins).
*
* Each rule's optional `series` hook runs **once** here (per rule, per call) to
* derive that rule's series-level facts; those facts are passed to the rule's
* `matches` and `groupBy` via the {@link RuleContext}. A rule only ever sees its
* own derived facts.
*
* Groups are returned in a **deterministic order** (sorted by their bucket key),
* so a series' display sets - and any identity derived from their position -
* are stable regardless of the order the imageIds were passed in.
*
* @param onUnmatched - called for each instance that matches no rule and is
* therefore placed in no group (e.g. a non-image SOP such as an SR or
* presentation state). Lets callers observe what was dropped instead of it
* disappearing silently.
*/
export function groupInstancesBySplitRules(
instances: NaturalizedInstance[],
splitRules: SplitRule[],
onUnmatched?: (instance: NaturalizedInstance) => void
): InstanceGroup[] {
if (!instances.length) {
return [];
}
// Derive each rule's series-level facts once for this split operation, so the
// per-instance `matches`/`groupBy` only read an already-computed value.
const ruleContexts: RuleContext[] = splitRules.map((rule) => ({
series: rule.series?.({ instances }) ?? {},
}));
const instancesMap = new Map<string, InstanceGroup>();
for (const instance of instances) {
let matched = false;
for (let ruleIndex = 0; ruleIndex < splitRules.length; ruleIndex++) {
const splitRule = splitRules[ruleIndex];
const context = ruleContexts[ruleIndex];
if (splitRule.matches && !splitRule.matches(instance, context)) {
continue;
}
matched = true;
const key = buildSplitKey(
instance,
context,
splitRule,
// Always include ruleIndex so two rules that reuse the same `id` can
// never collide into one bucket (and inherit the wrong matchedRule).
`${ruleIndex}:${splitRule.id ?? ''}`
);
let group = instancesMap.get(key);
if (!group) {
group = { instances: [], matchedRule: splitRule, splitKey: key };
instancesMap.set(key, group);
}
group.instances.push(instance);
break;
}
if (!matched) {
onUnmatched?.(instance);
}
}
return Array.from(instancesMap.values()).sort((a, b) =>
(a.splitKey ?? '').localeCompare(b.splitKey ?? '')
);
}
|