fhirsmith 0.13.3 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -1
- package/README.md +48 -4
- package/config-template.json +70 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/nginx.md +391 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher.js +146 -14
- package/server.js +51 -14
- package/stats.js +19 -13
- package/testing/readme.md +112 -0
- package/testing/render.js +482 -0
- package/testing/search.js +378 -0
- package/testing/store.js +266 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +486 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +142 -27
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/extensions.js +2 -2
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +111 -15
- package/tx/provider.js +85 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/sct/text-index.js +419 -0
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +20 -0
- package/tx/workers/metadata.js +29 -4
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +167 -20
- package/tx/workers/validate.js +61 -182
- package/tx/workers/worker.js +9 -0
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -16,6 +16,8 @@ const {ConceptMap} = require("../library/conceptmap");
|
|
|
16
16
|
const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl");
|
|
17
17
|
const {Issue} = require("../library/operation-outcome");
|
|
18
18
|
const {debugLog} = require("../operation-context");
|
|
19
|
+
const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index");
|
|
20
|
+
const Logger = require("../../library/logger");
|
|
19
21
|
|
|
20
22
|
// Symbol key under which a resolved ECL filter analysis is memoised directly on
|
|
21
23
|
// the ValueSet compose.include.filter element (`fc`). Symbol-keyed and defined
|
|
@@ -127,6 +129,9 @@ class SnomedServices {
|
|
|
127
129
|
this.strings = new SnomedStrings(sharedData.strings);
|
|
128
130
|
this.words = new SnomedWords(sharedData.words);
|
|
129
131
|
this.stems = new SnomedStems(sharedData.stems);
|
|
132
|
+
// Free-text search index, built on first search (see getTextIndex)
|
|
133
|
+
this._textIndex = null;
|
|
134
|
+
this._textIndexPromise = null;
|
|
130
135
|
this.refs = new SnomedReferences(sharedData.refs);
|
|
131
136
|
this.descriptions = new SnomedDescriptions(sharedData.desc);
|
|
132
137
|
this.descriptionIndex = new SnomedDescriptionIndex(sharedData.descRef);
|
|
@@ -1482,16 +1487,104 @@ class SnomedServices {
|
|
|
1482
1487
|
};
|
|
1483
1488
|
|
|
1484
1489
|
|
|
1485
|
-
|
|
1486
|
-
|
|
1490
|
+
/**
|
|
1491
|
+
* Free-text search over active descriptions.
|
|
1492
|
+
*
|
|
1493
|
+
* Uses the in-memory word index (see tx/sct/text-index.js), built the first
|
|
1494
|
+
* time this edition is searched: every filter token (stop words aside) must
|
|
1495
|
+
* match a word of the concept's active descriptions, either as a prefix or
|
|
1496
|
+
* by sharing its stem. Filters with no indexable token (only 1-character
|
|
1497
|
+
* tokens), or an edition whose index failed to build, fall back to
|
|
1498
|
+
* scanSearch().
|
|
1499
|
+
*
|
|
1500
|
+
* Previously the third parameter was `exactMatch`, which the provider fed
|
|
1501
|
+
* from its unrelated `sort` flag - so $expand with a filter on an
|
|
1502
|
+
* include-all-codes value set matched ANY term by brute-force scan: 10k+
|
|
1503
|
+
* matches for a typical multi-word filter, with the event loop blocked for
|
|
1504
|
+
* the whole scan (tx.fhir.org incident 2026-09-17).
|
|
1505
|
+
*
|
|
1506
|
+
* @param {SearchFilterText} searchText
|
|
1507
|
+
* @param {boolean} includeInactive - include inactive concepts
|
|
1508
|
+
* @param {OperationContext|null} opContext - for yielding / deadline checks
|
|
1509
|
+
* @returns {Promise<SnomedFilterContext>}
|
|
1510
|
+
*/
|
|
1511
|
+
async searchFilter(searchText, includeInactive = false, opContext = null) {
|
|
1512
|
+
const filterText = (searchText && searchText.filter ? searchText.filter : '').toLowerCase().trim();
|
|
1513
|
+
const tokens = filterTokens(filterText);
|
|
1514
|
+
if (tokens.length > 0) {
|
|
1515
|
+
const index = await this.getTextIndex(opContext);
|
|
1516
|
+
if (index) {
|
|
1517
|
+
const result = new SnomedFilterContext();
|
|
1518
|
+
const ordinals = index.search(tokens, opContext);
|
|
1519
|
+
if (opContext) {
|
|
1520
|
+
await opContext.checkAndYield('sct:searchFilter');
|
|
1521
|
+
}
|
|
1522
|
+
result.matches = rankMatches(this, index, ordinals, filterText, tokens, includeInactive);
|
|
1523
|
+
return result;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
return await this.scanSearch(filterText, includeInactive, opContext);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* The edition's text index, building it on first use. The build is shared:
|
|
1531
|
+
* concurrent searches wait on the same promise, and time spent waiting is
|
|
1532
|
+
* not charged to the waiting operation's compute budget. Resolves to null
|
|
1533
|
+
* if the build failed (the next search retries it).
|
|
1534
|
+
* @param {OperationContext|null} opContext
|
|
1535
|
+
* @returns {Promise<SnomedTextIndex|null>}
|
|
1536
|
+
*/
|
|
1537
|
+
async getTextIndex(opContext = null) {
|
|
1538
|
+
if (this._textIndex) {
|
|
1539
|
+
return this._textIndex;
|
|
1540
|
+
}
|
|
1541
|
+
if (!this._textIndexPromise) {
|
|
1542
|
+
const started = Date.now();
|
|
1543
|
+
// Logger fetched lazily: importers and tests require this module too
|
|
1544
|
+
const textIndexLog = Logger.getInstance().child({module: 'tx-sct'});
|
|
1545
|
+
this._textIndexPromise = SnomedTextIndex.build(this).then(index => {
|
|
1546
|
+
this._textIndex = index;
|
|
1547
|
+
textIndexLog.info(`SNOMED text index for ${this.versionUri}: ${index.words.length} words, built in ${Date.now() - started}ms`);
|
|
1548
|
+
return index;
|
|
1549
|
+
}, error => {
|
|
1550
|
+
this._textIndexPromise = null;
|
|
1551
|
+
textIndexLog.error(`SNOMED text index build failed for ${this.versionUri}: ${error.message}`);
|
|
1552
|
+
return null;
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
if (opContext && typeof opContext.waitFor === 'function') {
|
|
1556
|
+
return await opContext.waitFor(this._textIndexPromise, 'sct:textIndex');
|
|
1557
|
+
}
|
|
1558
|
+
return await this._textIndexPromise;
|
|
1559
|
+
}
|
|
1487
1560
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1561
|
+
/**
|
|
1562
|
+
* Brute-force scan of every concept's active descriptions: every
|
|
1563
|
+
* whitespace-separated term of the filter must appear (as a substring) in
|
|
1564
|
+
* the same description. Expensive (hundreds of ms on a full edition), so it
|
|
1565
|
+
* yields through opContext.checkAndYield and the compute deadline applies.
|
|
1566
|
+
*
|
|
1567
|
+
* @param {string} filterText - lower-cased, trimmed
|
|
1568
|
+
* @param {boolean} includeInactive
|
|
1569
|
+
* @param {OperationContext|null} opContext
|
|
1570
|
+
* @returns {Promise<SnomedFilterContext>}
|
|
1571
|
+
*/
|
|
1572
|
+
async scanSearch(filterText, includeInactive = false, opContext = null) {
|
|
1573
|
+
const result = new SnomedFilterContext();
|
|
1574
|
+
const searchTerms = filterText.split(/\s+/).filter(t => t.length > 0);
|
|
1490
1575
|
const matches = [];
|
|
1576
|
+
if (searchTerms.length === 0) {
|
|
1577
|
+
result.matches = matches;
|
|
1578
|
+
return result;
|
|
1579
|
+
}
|
|
1491
1580
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1581
|
+
const conceptCount = this.concepts.count();
|
|
1582
|
+
const conceptSize = this.concepts.constructor.CONCEPT_SIZE;
|
|
1583
|
+
for (let i = 0; i < conceptCount; i++) {
|
|
1584
|
+
if (opContext && (i & 0x3FF) === 0) {
|
|
1585
|
+
await opContext.checkAndYield('sct:searchFilter');
|
|
1586
|
+
}
|
|
1587
|
+
const conceptIndex = i * conceptSize;
|
|
1495
1588
|
|
|
1496
1589
|
try {
|
|
1497
1590
|
const concept = this.concepts.getConcept(conceptIndex);
|
|
@@ -1510,25 +1603,23 @@ class SnomedServices {
|
|
|
1510
1603
|
const description = this.descriptions.getDescription(descIndex);
|
|
1511
1604
|
if (description.active) {
|
|
1512
1605
|
const term = this.strings.getEntry(description.iDesc).toLowerCase();
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
// All search terms must be present
|
|
1516
|
-
matchFound = searchTerms.every(searchTerm => term.includes(searchTerm));
|
|
1517
|
-
} else {
|
|
1518
|
-
// Any search term can match
|
|
1519
|
-
matchFound = searchTerms.some(searchTerm => term.includes(searchTerm));
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
if (matchFound) {
|
|
1606
|
+
if (searchTerms.every(searchTerm => term.includes(searchTerm))) {
|
|
1607
|
+
matchFound = true;
|
|
1523
1608
|
// Calculate priority based on match quality
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1609
|
+
let p;
|
|
1610
|
+
if (term === filterText) {
|
|
1611
|
+
p = 100; // Exact match
|
|
1612
|
+
} else if (term.startsWith(filterText)) {
|
|
1613
|
+
p = 50; // Prefix match
|
|
1528
1614
|
} else {
|
|
1529
|
-
|
|
1615
|
+
p = 10; // Contains match
|
|
1616
|
+
}
|
|
1617
|
+
if (p > priority) {
|
|
1618
|
+
priority = p;
|
|
1619
|
+
}
|
|
1620
|
+
if (priority === 100) {
|
|
1621
|
+
break;
|
|
1530
1622
|
}
|
|
1531
|
-
break;
|
|
1532
1623
|
}
|
|
1533
1624
|
}
|
|
1534
1625
|
}
|
|
@@ -1541,6 +1632,9 @@ class SnomedServices {
|
|
|
1541
1632
|
});
|
|
1542
1633
|
}
|
|
1543
1634
|
} catch (error) {
|
|
1635
|
+
if (error instanceof Issue || (error && error.abandoned)) {
|
|
1636
|
+
throw error;
|
|
1637
|
+
}
|
|
1544
1638
|
// Skip problematic concepts
|
|
1545
1639
|
continue;
|
|
1546
1640
|
}
|
|
@@ -1582,6 +1676,19 @@ class SnomedProvider extends BaseCSServices {
|
|
|
1582
1676
|
return actualVersion && actualVersion.startsWith(checkVersion);
|
|
1583
1677
|
}
|
|
1584
1678
|
|
|
1679
|
+
/**
|
|
1680
|
+
* The display name of the code system, as $lookup reports it.
|
|
1681
|
+
*
|
|
1682
|
+
* Without this, CodeSystemProvider's fallback applies, which returns the versioned uri -
|
|
1683
|
+
* so a SNOMED lookup answered "http://snomed.info/sct|http://snomed.info/sct/731000124108
|
|
1684
|
+
* /version/20230301" where every other code system answers something like "LOINC". The
|
|
1685
|
+
* edition is named because for SNOMED that is the part worth knowing; the version
|
|
1686
|
+
* parameter carries the uri.
|
|
1687
|
+
*/
|
|
1688
|
+
name() {
|
|
1689
|
+
return this.sct.getDescription();
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1585
1692
|
description() {
|
|
1586
1693
|
return this.sct.getDescription();
|
|
1587
1694
|
}
|
|
@@ -2448,8 +2555,10 @@ class SnomedProvider extends BaseCSServices {
|
|
|
2448
2555
|
}
|
|
2449
2556
|
|
|
2450
2557
|
// Search filter
|
|
2558
|
+
// eslint-disable-next-line no-unused-vars
|
|
2451
2559
|
async searchFilter(filterContext, filter, sort) {
|
|
2452
|
-
|
|
2560
|
+
// `sort` is not a match-mode flag: results are always ranked.
|
|
2561
|
+
let f = await this.sct.searchFilter(filter, false, this.opContext);
|
|
2453
2562
|
filterContext.filters.push(f);
|
|
2454
2563
|
return f;
|
|
2455
2564
|
}
|
|
@@ -2691,7 +2800,7 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
2691
2800
|
|
|
2692
2801
|
if (url.startsWith('http://snomed.info/sct?fhir_vs') ||
|
|
2693
2802
|
url.startsWith(`http://snomed.info/sct/${this.edition}?fhir_vs`) ||
|
|
2694
|
-
url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version}?fhir_vs`)) {
|
|
2803
|
+
url.startsWith(`http://snomed.info/sct/${this.edition}/version/${this.version()}?fhir_vs`)) {
|
|
2695
2804
|
id = url.substring(qIdx);
|
|
2696
2805
|
} else {
|
|
2697
2806
|
return null;
|
|
@@ -2909,8 +3018,8 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
2909
3018
|
description: `The concept map implicitly defined by the ${name} Association Reference Set`,
|
|
2910
3019
|
copyright: 'This value set includes content from SNOMED CT, which is copyright © 2002+ International Health Terminology Standards Development Organisation (SNOMED International), and distributed by agreement between SNOMED International and HL7',
|
|
2911
3020
|
status: 'active',
|
|
2912
|
-
sourceUri: `${this.system}?fhir_vs`,
|
|
2913
|
-
targetUri: `${this.system}?fhir_vs`,
|
|
3021
|
+
sourceUri: `${this.system()}?fhir_vs`,
|
|
3022
|
+
targetUri: `${this.system()}?fhir_vs`,
|
|
2914
3023
|
group: [{
|
|
2915
3024
|
source: 'http://snomed.info/sct',
|
|
2916
3025
|
target: 'http://snomed.info/sct'
|
|
@@ -2931,6 +3040,9 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider {
|
|
|
2931
3040
|
function getEditionName(edition) {
|
|
2932
3041
|
const editionMap = {
|
|
2933
3042
|
'900000000000207008': 'International Edition',
|
|
3043
|
+
// The terminology-ecosystem test distribution, published under xsct - see the
|
|
3044
|
+
// tx-ecosystem IG, which requires a server claiming the snomed mode to load it.
|
|
3045
|
+
'31000003106': 'Test Edition',
|
|
2934
3046
|
'449081005': 'International Spanish Edition',
|
|
2935
3047
|
'11000221109': 'Argentinian Edition',
|
|
2936
3048
|
'32506021000036107': 'Australian Edition (with drug extension)',
|
|
@@ -2967,6 +3079,9 @@ function getEditionName(edition) {
|
|
|
2967
3079
|
function getEditionCode(edition) {
|
|
2968
3080
|
const editionMap = {
|
|
2969
3081
|
'900000000000207008': 'Intl',
|
|
3082
|
+
// The terminology-ecosystem test distribution, published under xsct - see the
|
|
3083
|
+
// tx-ecosystem IG, which requires a server claiming the snomed mode to load it.
|
|
3084
|
+
'31000003106': 'Test',
|
|
2970
3085
|
'449081005': 'es',
|
|
2971
3086
|
'11000221109': 'AR-es',
|
|
2972
3087
|
'32506021000036107': 'AU+',
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
<!-- Text Search: the whole code system as an anonymous value set, expanded with the
|
|
2
|
+
text as the filter parameter. This is what a person opening a code system usually
|
|
3
|
+
wants - "is there a code for X" - so it comes first. -->
|
|
4
|
+
<div class="operation-form" style="margin-bottom: 15px;">
|
|
5
|
+
<strong>Text Search</strong>
|
|
6
|
+
<form method="post" action="{{ expandUrl }}" id="{{ searchId }}_form"
|
|
7
|
+
data-system="{{ url }}" data-version="{{ systemVersion }}"
|
|
8
|
+
style="display: inline-block; margin-left: 10px;" onsubmit="return txSearchSubmit('{{ searchId }}')">
|
|
9
|
+
<input type="text" name="filter" placeholder="Text" size="30"/>
|
|
10
|
+
<button type="submit" class="btn btn-sm btn-primary">Search</button>
|
|
11
|
+
</form>
|
|
12
|
+
<div id="{{ searchId }}_preview" style="display: none; margin-left: 10px; margin-top: 8px;"></div>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
1
15
|
<div class="operation-form" style="margin-bottom: 15px;">
|
|
2
16
|
<strong>Lookup</strong>
|
|
3
17
|
<form method="get" action="$lookup" style="display: inline-block; margin-left: 10px;">
|
|
@@ -16,3 +30,166 @@
|
|
|
16
30
|
<button type="submit" class="btn btn-sm btn-primary">Subsumes</button>
|
|
17
31
|
</form>
|
|
18
32
|
</div>
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
<!-- Filter: builds a throwaway ValueSet (uuid url, one include for this code system carrying
|
|
36
|
+
every filter row) and posts it to $expand, replacing the page with the rendered expansion. -->
|
|
37
|
+
<div class="operation-form" style="margin-bottom: 15px;">
|
|
38
|
+
<strong>Filter</strong>
|
|
39
|
+
<form method="post" action="{{ expandUrl }}" id="{{ filterId }}_form"
|
|
40
|
+
data-system="{{ url }}" data-version="{{ systemVersion }}"
|
|
41
|
+
style="margin-left: 10px; margin-top: 5px;" onsubmit="return txFilterSubmit('{{ filterId }}')">
|
|
42
|
+
<datalist id="{{ filterId }}_props">{{ filterPropertyOptions }}</datalist>
|
|
43
|
+
<table class="grid" cellpadding="0" cellspacing="0">
|
|
44
|
+
<thead>
|
|
45
|
+
<tr><th>Property</th><th>Operation</th><th>Value</th><th></th></tr>
|
|
46
|
+
</thead>
|
|
47
|
+
<tbody id="{{ filterId }}_rows">
|
|
48
|
+
<tr>
|
|
49
|
+
<td><input type="text" name="property" list="{{ filterId }}_props" size="24" autocomplete="off"/></td>
|
|
50
|
+
<td><select name="op">{{ filterOpOptions }}</select></td>
|
|
51
|
+
<td><input type="text" name="value" size="24"/></td>
|
|
52
|
+
<td><button type="button" class="btn btn-sm" title="Remove this filter" onclick="txFilterRemoveRow(this)">×</button></td>
|
|
53
|
+
</tr>
|
|
54
|
+
</tbody>
|
|
55
|
+
</table>
|
|
56
|
+
<button type="button" class="btn btn-sm" onclick="txFilterAddRow('{{ filterId }}')">Add Filter</button>
|
|
57
|
+
<button type="submit" class="btn btn-sm btn-primary">Expand</button>
|
|
58
|
+
</form>
|
|
59
|
+
<div id="{{ filterId }}_preview" style="display: none; margin-left: 10px; margin-top: 8px;"></div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<script>
|
|
63
|
+
// The two forms on this tab that expand something both do the same thing: build an
|
|
64
|
+
// anonymous value set over the code system being viewed and post it to $expand, then
|
|
65
|
+
// replace the page with whatever comes back. They differ only in what they put on the
|
|
66
|
+
// include (nothing, for a text search) and what they put on the request (the filter).
|
|
67
|
+
|
|
68
|
+
function txOpMessage(id, text) {
|
|
69
|
+
var preview = document.getElementById(id + '_preview');
|
|
70
|
+
preview.textContent = text;
|
|
71
|
+
preview.style.display = 'block';
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function txOpUuid() {
|
|
76
|
+
if (window.crypto && typeof crypto.randomUUID === 'function') {
|
|
77
|
+
return crypto.randomUUID();
|
|
78
|
+
}
|
|
79
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
80
|
+
var r = Math.random() * 16 | 0;
|
|
81
|
+
return (c === 'x' ? r : ((r & 0x3) | 0x8)).toString(16);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// One include, so that filters are ANDed. The value set exists only for the duration of
|
|
86
|
+
// the expansion - it is never stored - so its url is a uuid.
|
|
87
|
+
function txOpBuildValueSet(form, filters) {
|
|
88
|
+
var include = { system: form.dataset.system };
|
|
89
|
+
if (form.dataset.version) {
|
|
90
|
+
include.version = form.dataset.version;
|
|
91
|
+
}
|
|
92
|
+
if (filters && filters.length > 0) {
|
|
93
|
+
include.filter = filters.map(function (f) {
|
|
94
|
+
return { property: f.property, op: f.op, value: f.value };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
resourceType: 'ValueSet',
|
|
99
|
+
// Marks the value set as one that exists only for this request, so that the server
|
|
100
|
+
// renders the expansion rather than a summary of a value set nobody can look up.
|
|
101
|
+
extension: [{ url: 'http://healthintersections.com.au/fhirsmith/StructureDefinition/valueset-transient', valueBoolean: true }],
|
|
102
|
+
url: 'urn:uuid:' + txOpUuid(),
|
|
103
|
+
status: 'active',
|
|
104
|
+
compose: { include: [include] }
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The value set goes in the body; anything that is an expansion parameter rather than
|
|
109
|
+
// part of the value set goes on the query, which $expand reads either way.
|
|
110
|
+
function txOpExpand(id, form, valueSet, params) {
|
|
111
|
+
var url = new URL(form.getAttribute('action'), window.location.href);
|
|
112
|
+
// The definition has to come back for the transient marker and the filters to survive
|
|
113
|
+
// the expansion - $expand drops both along with the rest of the definition otherwise.
|
|
114
|
+
url.searchParams.set('includeDefinition', 'true');
|
|
115
|
+
for (var name in params || {}) {
|
|
116
|
+
url.searchParams.set(name, params[name]);
|
|
117
|
+
}
|
|
118
|
+
txOpMessage(id, 'Expanding...');
|
|
119
|
+
fetch(url.toString(), {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/fhir+json', 'Accept': 'text/html' },
|
|
122
|
+
body: JSON.stringify(valueSet)
|
|
123
|
+
}).then(function (response) {
|
|
124
|
+
return response.text();
|
|
125
|
+
}).then(function (html) {
|
|
126
|
+
document.open();
|
|
127
|
+
document.write(html);
|
|
128
|
+
document.close();
|
|
129
|
+
}).catch(function (err) {
|
|
130
|
+
txOpMessage(id, 'Expansion failed: ' + err.message);
|
|
131
|
+
});
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function txSearchSubmit(id) {
|
|
136
|
+
var form = document.getElementById(id + '_form');
|
|
137
|
+
var text = form.querySelector('input[name="filter"]').value.trim();
|
|
138
|
+
if (text === '') {
|
|
139
|
+
return txOpMessage(id, 'Enter some text to search for.');
|
|
140
|
+
}
|
|
141
|
+
return txOpExpand(id, form, txOpBuildValueSet(form, null), { filter: text });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function txFilterClearRow(row) {
|
|
145
|
+
row.querySelectorAll('input').forEach(function (input) { input.value = ''; });
|
|
146
|
+
row.querySelectorAll('select').forEach(function (select) { select.selectedIndex = 0; });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function txFilterAddRow(id) {
|
|
150
|
+
var rows = document.getElementById(id + '_rows');
|
|
151
|
+
var row = rows.rows[0].cloneNode(true);
|
|
152
|
+
txFilterClearRow(row);
|
|
153
|
+
rows.appendChild(row);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function txFilterRemoveRow(btn) {
|
|
157
|
+
var row = btn.closest('tr');
|
|
158
|
+
var rows = row.parentNode;
|
|
159
|
+
if (rows.rows.length > 1) {
|
|
160
|
+
rows.removeChild(row);
|
|
161
|
+
} else {
|
|
162
|
+
txFilterClearRow(row);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Every row that has anything in it. Rows left entirely blank are ignored, so an unused
|
|
167
|
+
// extra row does not stop the expansion; a half-filled one is an error the user must fix.
|
|
168
|
+
function txFilterCollect(id) {
|
|
169
|
+
var filters = [];
|
|
170
|
+
var rows = document.getElementById(id + '_rows').rows;
|
|
171
|
+
for (var i = 0; i < rows.length; i++) {
|
|
172
|
+
var property = rows[i].querySelector('input[name="property"]').value.trim();
|
|
173
|
+
var op = rows[i].querySelector('select[name="op"]').value;
|
|
174
|
+
var value = rows[i].querySelector('input[name="value"]').value.trim();
|
|
175
|
+
if (property !== '' || value !== '') {
|
|
176
|
+
filters.push({ row: i + 1, property: property, op: op, value: value });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return filters;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function txFilterSubmit(id) {
|
|
183
|
+
var form = document.getElementById(id + '_form');
|
|
184
|
+
var filters = txFilterCollect(id);
|
|
185
|
+
if (filters.length === 0) {
|
|
186
|
+
return txOpMessage(id, 'No filters specified.');
|
|
187
|
+
}
|
|
188
|
+
for (var i = 0; i < filters.length; i++) {
|
|
189
|
+
if (filters[i].property === '' || filters[i].value === '') {
|
|
190
|
+
return txOpMessage(id, 'Filter ' + filters[i].row + ' needs both a property and a value.');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return txOpExpand(id, form, txOpBuildValueSet(form, filters), null);
|
|
194
|
+
}
|
|
195
|
+
</script>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<p>Expand a SNOMED CT expression constraint. The expression is run as a value set that
|
|
2
|
+
composes SNOMED CT with a <code>constraint</code> filter; the result appears below, and
|
|
3
|
+
the inputs stay where they are so the expression can be adjusted and run again.</p>
|
|
4
|
+
|
|
5
|
+
{% if hasVersions %}
|
|
6
|
+
<div class="operation-form" style="margin-bottom: 15px;">
|
|
7
|
+
<form id="{{ eclId }}_form" action="{{ expandUrl }}" data-system="{{ system }}"
|
|
8
|
+
data-transient="{{ transientExtension }}" onsubmit="return eclRun('{{ eclId }}')">
|
|
9
|
+
<table class="grid" cellpadding="0" cellspacing="0" style="width: 100%;">
|
|
10
|
+
<tr>
|
|
11
|
+
<td style="width: 100%;">ECL: <input type="text" name="ecl" id="{{ eclId }}_ecl" size="80"
|
|
12
|
+
style="width: 90%;" autocomplete="off" placeholder="< 404684003 |Clinical finding|"/></td>
|
|
13
|
+
</tr>
|
|
14
|
+
<tr>
|
|
15
|
+
<td>Edition: <select name="version" id="{{ eclId }}_version">{{ versionOptions }}</select>
|
|
16
|
+
<button type="submit" class="btn btn-sm btn-primary" id="{{ eclId }}_go">Go</button>
|
|
17
|
+
</td>
|
|
18
|
+
</tr>
|
|
19
|
+
</table>
|
|
20
|
+
</form>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div id="{{ eclId }}_results"></div>
|
|
24
|
+
|
|
25
|
+
<script>
|
|
26
|
+
function eclUuid() {
|
|
27
|
+
if (window.crypto && typeof crypto.randomUUID === 'function') {
|
|
28
|
+
return crypto.randomUUID();
|
|
29
|
+
}
|
|
30
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
31
|
+
var r = Math.random() * 16 | 0;
|
|
32
|
+
return (c === 'x' ? r : ((r & 0x3) | 0x8)).toString(16);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function eclMessage(id, text) {
|
|
37
|
+
document.getElementById(id + '_results').innerHTML = '';
|
|
38
|
+
var p = document.createElement('p');
|
|
39
|
+
p.textContent = text;
|
|
40
|
+
document.getElementById(id + '_results').appendChild(p);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The expansion comes back as a fragment rather than a page, so it can go straight under
|
|
45
|
+
// the inputs. An error arrives the same way - as the rendered OperationOutcome - which is
|
|
46
|
+
// the point: an ECL is usually wrong several times before it is right, and the message
|
|
47
|
+
// belongs next to the expression that caused it.
|
|
48
|
+
function eclRun(id) {
|
|
49
|
+
var form = document.getElementById(id + '_form');
|
|
50
|
+
var ecl = document.getElementById(id + '_ecl').value.trim();
|
|
51
|
+
var version = document.getElementById(id + '_version').value;
|
|
52
|
+
var go = document.getElementById(id + '_go');
|
|
53
|
+
|
|
54
|
+
if (ecl === '') {
|
|
55
|
+
return eclMessage(id, 'Enter an expression constraint.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
var valueSet = {
|
|
59
|
+
resourceType: 'ValueSet',
|
|
60
|
+
extension: [{ url: form.dataset.transient, valueBoolean: true }],
|
|
61
|
+
url: 'urn:uuid:' + eclUuid(),
|
|
62
|
+
status: 'active',
|
|
63
|
+
compose: {
|
|
64
|
+
include: [{
|
|
65
|
+
system: form.dataset.system,
|
|
66
|
+
version: version,
|
|
67
|
+
filter: [{ property: 'constraint', op: '=', value: ecl }]
|
|
68
|
+
}]
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
eclMessage(id, 'Expanding...');
|
|
73
|
+
go.disabled = true;
|
|
74
|
+
fetch(form.getAttribute('action'), {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/fhir+json', 'Accept': 'text/html' },
|
|
77
|
+
body: JSON.stringify(valueSet)
|
|
78
|
+
}).then(function (response) {
|
|
79
|
+
return response.text();
|
|
80
|
+
}).then(function (html) {
|
|
81
|
+
document.getElementById(id + '_results').innerHTML = html;
|
|
82
|
+
}).catch(function (err) {
|
|
83
|
+
eclMessage(id, 'Expansion failed: ' + err.message);
|
|
84
|
+
}).then(function () {
|
|
85
|
+
go.disabled = false;
|
|
86
|
+
});
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
</script>
|
|
90
|
+
{% else %}
|
|
91
|
+
<p><em>This endpoint has no SNOMED CT editions loaded, so there is nothing to run an
|
|
92
|
+
expression constraint against.</em></p>
|
|
93
|
+
{% endif %}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<canvas id="memoryChart" height="200"></canvas>
|
|
5
5
|
</div>
|
|
6
6
|
<div style="flex: 1;">
|
|
7
|
-
<h6>
|
|
8
|
-
<canvas id="
|
|
7
|
+
<h6>Requests per Minute</h6>
|
|
8
|
+
<canvas id="requestChart" height="200"></canvas>
|
|
9
9
|
</div>
|
|
10
10
|
<div style="flex: 1;">
|
|
11
11
|
<h6>Turnaround Time</h6>
|
|
@@ -13,18 +13,18 @@
|
|
|
13
13
|
</div>
|
|
14
14
|
</div>
|
|
15
15
|
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
|
|
16
|
-
<div style="flex: 1;">
|
|
17
|
-
<h6>Requests per Minute</h6>
|
|
18
|
-
<canvas id="requestChart" height="200"></canvas>
|
|
19
|
-
</div>
|
|
20
16
|
<div style="flex: 1;">
|
|
21
17
|
<h6>Client Cache</h6>
|
|
22
18
|
<canvas id="clientCacheChart" height="200"></canvas>
|
|
23
19
|
</div>
|
|
24
20
|
<div style="flex: 1;">
|
|
25
|
-
<h6>Node Blocking</h6>
|
|
21
|
+
<h6>Node Blocking (mean)</h6>
|
|
26
22
|
<canvas id="blockChart" height="200"></canvas>
|
|
27
23
|
</div>
|
|
24
|
+
<div style="flex: 1;">
|
|
25
|
+
<h6>Node Blocking (max)</h6>
|
|
26
|
+
<canvas id="blockMaxChart" height="200"></canvas>
|
|
27
|
+
</div>
|
|
28
28
|
</div>
|
|
29
29
|
|
|
30
30
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
@@ -99,36 +99,6 @@
|
|
|
99
99
|
}
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
// Expansion cache chart
|
|
103
|
-
new Chart(document.getElementById('cacheChart'), {
|
|
104
|
-
type: 'line',
|
|
105
|
-
data: {
|
|
106
|
-
labels: historyData.map(d => formatTime(d.time)),
|
|
107
|
-
datasets: [{
|
|
108
|
-
data: historyData.map(d => d.expansion),
|
|
109
|
-
borderColor: '#7b7b7b',
|
|
110
|
-
backgroundColor: 'rgba(123, 123, 123, 0.1)',
|
|
111
|
-
fill: true,
|
|
112
|
-
tension: 0.3,
|
|
113
|
-
pointRadius: 2
|
|
114
|
-
}]
|
|
115
|
-
},
|
|
116
|
-
options: {
|
|
117
|
-
...chartOptions,
|
|
118
|
-
scales: {
|
|
119
|
-
...chartOptions.scales,
|
|
120
|
-
y: {
|
|
121
|
-
...chartOptions.scales.y,
|
|
122
|
-
title: {
|
|
123
|
-
display: true,
|
|
124
|
-
text: 'Expansion cache # items'
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
|
|
132
102
|
// Tat chart
|
|
133
103
|
new Chart(document.getElementById('tatChart'), {
|
|
134
104
|
type: 'line',
|
|
@@ -215,48 +185,41 @@
|
|
|
215
185
|
}
|
|
216
186
|
}
|
|
217
187
|
});
|
|
218
|
-
// Blocking
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// request-freezing stalls.
|
|
235
|
-
label: 'max',
|
|
236
|
-
data: historyData.map(d => d.blockMax),
|
|
237
|
-
borderColor: '#a72828',
|
|
238
|
-
fill: false,
|
|
239
|
-
tension: 0.3,
|
|
240
|
-
pointRadius: 2
|
|
241
|
-
}]
|
|
242
|
-
},
|
|
243
|
-
options: {
|
|
244
|
-
...chartOptions,
|
|
245
|
-
plugins: {
|
|
246
|
-
...chartOptions.plugins,
|
|
247
|
-
legend: { display: true }
|
|
188
|
+
// Blocking charts. Mean and max are separate charts because they are on different
|
|
189
|
+
// scales: a multi-second stall puts max in the thousands of ms while the mean sits at
|
|
190
|
+
// a few ms, so on one chart the mean is a flat line along the bottom.
|
|
191
|
+
function blockChart(id, field, colour, fill) {
|
|
192
|
+
new Chart(document.getElementById(id), {
|
|
193
|
+
type: 'line',
|
|
194
|
+
data: {
|
|
195
|
+
labels: historyData.map(d => formatTime(d.time)),
|
|
196
|
+
datasets: [{
|
|
197
|
+
data: historyData.map(d => d[field]),
|
|
198
|
+
borderColor: colour,
|
|
199
|
+
backgroundColor: fill,
|
|
200
|
+
fill: true,
|
|
201
|
+
tension: 0.3,
|
|
202
|
+
pointRadius: 2
|
|
203
|
+
}]
|
|
248
204
|
},
|
|
249
|
-
|
|
250
|
-
...chartOptions
|
|
251
|
-
|
|
252
|
-
...chartOptions.scales
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
205
|
+
options: {
|
|
206
|
+
...chartOptions,
|
|
207
|
+
scales: {
|
|
208
|
+
...chartOptions.scales,
|
|
209
|
+
y: {
|
|
210
|
+
...chartOptions.scales.y,
|
|
211
|
+
title: {
|
|
212
|
+
display: true,
|
|
213
|
+
text: 'ms'
|
|
214
|
+
}
|
|
256
215
|
}
|
|
257
216
|
}
|
|
258
217
|
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// the average event-loop delay over each interval
|
|
221
|
+
blockChart('blockChart', 'block', '#2845a7', 'rgba(40, 69, 167, 0.1)');
|
|
222
|
+
// the worst single stall in each interval: the one that shows request-freezing blocks
|
|
223
|
+
blockChart('blockMaxChart', 'blockMax', '#a72828', 'rgba(167, 40, 40, 0.1)');
|
|
261
224
|
})();
|
|
262
225
|
</script>
|