Assembly:
@@ -422,12 +439,12 @@ import useFormatters from '@/composition/formatters'
import useItem from '@/composition/item'
import useRemoteData from '@/composition/remote-data'
import config from '@/config'
+import { textForTargetGeneCategory } from '@/lib/target-genes';
import {saveChartAsFile} from '@/lib/chart-export'
import { parseScoresOrCounts } from '@/lib/scores'
-import { variantNotNullOrNA } from '@/lib/mave-hgvs';
+import { preferredVariantLabel, variantNotNullOrNA } from '@/lib/mave-hgvs';
import { mapState } from 'vuex'
import { ref } from 'vue'
-import items from '@/composition/items'
export default {
name: 'ScoreSetView',
@@ -439,6 +456,40 @@ export default {
['familyName', 'givenName', 'orcidId']
)
},
+ evidenceStrengths: function() {
+ if (config.CLINICAL_FEATURES_ENABLED) {
+ return {
+ 'urn:mavedb:00000050-a-1': {
+ oddsOfPathogenicity: {
+ abnormal: 24.9,
+ normal: 0.043
+ },
+ evidenceCodes: {
+ abnormal: 'PS3_Strong',
+ normal: 'BS3_Strong'
+ },
+ source: 'https://pubmed.ncbi.nlm.nih.gov/36550560/'
+ },
+ 'urn:mavedb:00000097-0-1': {
+ oddsOfPathogenicity: {
+ abnormal: 52.4,
+ normal: 0.02
+ },
+ evidenceCodes: {
+ abnormal: 'PS3_Strong',
+ normal: 'BS3_Strong'
+ },
+ source: 'https://pubmed.ncbi.nlm.nih.gov/34793697/',
+ exampleVariant: {
+ urn: 'urn:mavedb:00000097-0-1#1697',
+ name: 'NM_007294.4(BRCA1):c.5237A>C (p.His1746Pro)'
+ }
+ }
+ }[this.item.urn] || null
+ } else {
+ return null
+ }
+ },
isMetaDataEmpty: function() {
//If extraMetadata is empty, return value will be true.
return Object.keys(this.item.extraMetadata).length === 0
@@ -459,6 +510,9 @@ export default {
// While a user is autocompleting, `this.selectedVariant` is a string. Once selected, it will become an object and we can pass it as a prop.
return typeof this.selectedVariant === 'object' ? this.selectedVariant : null
},
+ urlVariant: function() {
+ return this.$route.query.variant
+ },
...mapState({
galaxyUrl: state => state.routeProps.galaxyUrl,
toolId: state => state.routeProps.toolId,
@@ -481,6 +535,7 @@ export default {
setScoresDataUrl: scoresRemoteData.setDataUrl,
ensureScoresDataLoaded: scoresRemoteData.ensureDataLoaded,
variantSearchSuggestions,
+ textForTargetGeneCategory: textForTargetGeneCategory
}
},
props: {
@@ -531,8 +586,16 @@ export default {
scoresData: {
handler: function(newValue) {
this.scores = newValue ? Object.freeze(parseScoresOrCounts(newValue)) : null
+ this.applyUrlState()
}
- }
+ },
+ selectedVariant: {
+ handler: function(newValue) {
+ this.$router.push({query: {
+ ...(this.selectedVariant && this.selectedVariant.accession) ? {variant: this.selectedVariant.accession} : {},
+ }})
+ }
+ },
},
methods: {
variantNotNullOrNA,
@@ -780,37 +843,36 @@ export default {
variantSearch: function(event) {
const matches = []
for (const variant of this.scores) {
- if (
- (variantNotNullOrNA(variant.hgvs_pro) && variant.hgvs_pro.toLowerCase().includes(event.query.toLowerCase()))
- || (variantNotNullOrNA(variant.hgvs_nt) && variant.hgvs_nt.toLowerCase().includes(event.query.toLowerCase()))
- || (variantNotNullOrNA(variant.hgvs_splice) && variant.hgvs_splice.toLowerCase().includes(event.query.toLowerCase()))
- ) {
- matches.push(variant)
+ if (variantNotNullOrNA(variant.hgvs_nt) && variant.hgvs_nt.toLowerCase().includes(event.query.toLowerCase())) {
+ matches.push(Object.assign(variant, {mavedb_label: variant.hgvs_nt}))
+ } else if (variantNotNullOrNA(variant.hgvs_splice) && variant.hgvs_splice.toLowerCase().includes(event.query.toLowerCase())) {
+ matches.push(Object.assign(variant, {mavedb_label: variant.hgvs_splice}))
+ } else if (variantNotNullOrNA(variant.hgvs_pro) && variant.hgvs_pro.toLowerCase().includes(event.query.toLowerCase())) {
+ matches.push(Object.assign(variant, {mavedb_label: variant.hgvs_pro}))
+ } else if (variantNotNullOrNA(variant.accession) && variant.accession.toLowerCase().includes(event.query.toLowerCase())) {
+ matches.push(Object.assign(variant, {mavedb_label: variant.accession}))
}
}
this.variantSearchSuggestions = matches
},
- variantSearchLabel: function(selectedVariant) {
- var displayStr = ""
- if (variantNotNullOrNA(selectedVariant.hgvs_nt)) {
- displayStr += `Nucleotide variant: ${selectedVariant.hgvs_nt}; `
- }
- if (variantNotNullOrNA(selectedVariant.hgvs_pro)) {
- displayStr += `Protein variant: ${selectedVariant.hgvs_pro}; `
- }
- if (variantNotNullOrNA(selectedVariant.hgvs_splice)) {
- displayStr += `Splice variant: ${selectedVariant.hgvs_splice}`
+ childComponentSelectedVariant: function(variant) {
+ if (variant == null) {
+ this.selectedVariant = null
}
- return displayStr.trim().replace(/;$/, '')
- },
- childComponentSelectedVariant: function(variant) {
if (!variant?.accession) {
return
}
- this.selectedVariant = this.scores.find((v) => v.accession == variant.accession)
+ const selectedVariant = this.scores.find((v) => v.accession == variant.accession)
+ this.selectedVariant = Object.assign(selectedVariant, preferredVariantLabel(selectedVariant))
+ },
+ applyUrlState: function() {
+ if (this.$route.query.variant) {
+ const selectedVariant = this.scores.find((v) => v.accession == this.$route.query.variant)
+ this.selectedVariant = Object.assign(selectedVariant, preferredVariantLabel(selectedVariant))
+ }
},
heatmapVisibilityUpdated: function(visible) {
this.heatmapExists = visible
@@ -886,8 +948,17 @@ export default {
}
.mave-score-set-variant-search {
- margin: 10px 0;
+ margin-top: 40px;
+ margin-bottom: 8px;
+ display: flex;
+ justify-content: center;
+}
+
+.mave-score-set-variant-search > span {
+ width: 50%;
display: flex;
+ align-items: center;
+ column-gap: .5em;
}
.p-float-label {
@@ -911,13 +982,40 @@ export default {
}
.mave-score-set-urn {
- /*font-family: Helvetica, Verdana, Arial, sans-serif;*/
+ font-size: 20px;
+ color: gray;
+ margin-left: 12px;
}
.mave-contributor {
margin: 0 0.5em;
}
+/* Evidence strength */
+
+table.mave-odds-path-table {
+ border-collapse: collapse;
+ margin: 1em auto 0.5em auto;
+}
+
+table.mave-odds-path-table td,
+table.mave-odds-path-table th {
+ border: 1px solid gray;
+ padding: 0.5em 1em;
+ text-align: center;
+}
+
+table.mave-odds-path-table td.mave-evidence-code-PS3_Strong {
+ background-color: #b02418;
+ color: white;
+ font-weight: bold;
+}
+table.mave-odds-path-table td.mave-evidence-code-BS3_Strong {
+ background-color: #385492;
+ color: white;
+ font-weight: bold;
+}
+
/* Formatting in Markdown blocks */
.mave-score-set-abstract {
diff --git a/src/components/screens/SearchView.vue b/src/components/screens/SearchView.vue
index 79fd428e..56426448 100644
--- a/src/components/screens/SearchView.vue
+++ b/src/components/screens/SearchView.vue
@@ -16,7 +16,7 @@
-
+
@@ -59,6 +59,7 @@ import { defineComponent } from 'vue'
import { paths, components } from '@/schema/openapi'
import type {LocationQueryValue} from "vue-router";
+import { textForTargetGeneCategory } from '@/lib/target-genes'
type ShortScoreSet = components['schemas']['ShortScoreSet']
type ShortTargetGene = components['schemas']['ShortTargetGene']
@@ -131,6 +132,7 @@ export default defineComponent({
language: {
emptyTable: 'Type in the search box above or use the filters to find a data set.'
},
+ textForTargetGeneCategory: textForTargetGeneCategory,
}
},
computed: {
diff --git a/src/components/screens/VariantScreen.vue b/src/components/screens/VariantScreen.vue
new file mode 100644
index 00000000..f1ea61d4
--- /dev/null
+++ b/src/components/screens/VariantScreen.vue
@@ -0,0 +1,220 @@
+
+
+
+
+
+ Variant: NM_007294.4(BRCA1):c.5237A>C (p.His1746Pro)
+
+
+ Functionally Abnormal
+
+
+
+ | ClinVar allele ID: |
+
+ 235927
+ |
+ |
+
+
+ | Variant type: |
+ Single nucleotide variant |
+ |
+ Functional consequence: |
+ Functionally Abnormal |
+
+
+ | Genomic location: |
+ 17:43057092 (GRCh38) 17:41209109 (GRCh37) |
+ |
+ Functional score: |
+ 2.57 |
+
+
+
+
+
+
+
+
+ BRCA1 Locus
+
+ Score set: {{ item.title }}
+
+
+
+
+
+ | Odds Path Abnormal* |
+ Odds Path Normal* |
+
+
+ | {{ evidenceStrengths.oddsOfPathogenicity.abnormal }} |
+ {{ evidenceStrengths.oddsOfPathogenicity.normal }} |
+
+
+ | {{ evidenceStrengths.evidenceCodes.abnormal }} |
+ {{ evidenceStrengths.evidenceCodes.normal }} |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/config.d.ts b/src/config.d.ts
index 7b849807..718b18ee 100644
--- a/src/config.d.ts
+++ b/src/config.d.ts
@@ -2,6 +2,8 @@ declare namespace Config {
let apiBaseUrl: string
let appBaseUrl: string
let orcidClientId: string
+ let CLINICAL_FEATURES_ENABLED: string
+
}
export default Config
diff --git a/src/config.js b/src/config.js
index 087abb98..ff752233 100644
--- a/src/config.js
+++ b/src/config.js
@@ -1,5 +1,6 @@
export default {
apiBaseUrl: import.meta.env.VITE_API_URL,
appBaseUrl: import.meta.env.VITE_APP_URL,
- orcidClientId: 'APP-GXFVWWJT8H0F50WD'
+ orcidClientId: 'APP-GXFVWWJT8H0F50WD',
+ CLINICAL_FEATURES_ENABLED: import.meta.env.CLINICAL_FEATURES_ENABLED
}
diff --git a/src/lib/clinvar.ts b/src/lib/clinvar.ts
new file mode 100644
index 00000000..a1c132ea
--- /dev/null
+++ b/src/lib/clinvar.ts
@@ -0,0 +1,45 @@
+export const CLINVAR_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = [{
+ name: 'Pathogenic',
+ description: 'Pathogenic variant',
+ shortDescription: 'Pathogenic'
+}, {
+ name: 'Likely_pathogenic',
+ description: 'Likely pathogenic variant',
+ shortDescription: 'LP'
+}, {
+ name: 'Pathogenic/Likely_pathogenic',
+ description: 'Pathogenic/Likely pathogenic variant (in different submissions)',
+ shortDescription: 'Path/LP (both)'
+}, {
+ name: 'Benign',
+ description: 'Benign variant',
+ shortDescription: 'Benign'
+}, {
+ name: 'Likely_benign',
+ description: 'Likely benign variant',
+ shortDescription: 'LB'
+}, {
+ name: 'Benign/Likely_benign',
+ description: 'Benign/Likely benign variant (in different submissions)',
+ shortDescription: 'B/LB (both)'
+}, {
+ name: 'Uncertain_significance',
+ description: 'Variant of uncertain significance',
+ shortDescription: 'VUS'
+}, {
+ name: 'Conflicting_interpretations_of_pathogenicity',
+ description: 'Variant with conflicting interpretations of pathogenicity',
+ shortDescription: 'Conflicting'
+}]
+
+export const BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = ['Likely_benign', 'Benign', 'Benign/Likely_benign']
+
+export const PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = ['Likely_pathogenic', 'Pathogenic', 'Pathogenic/Likely_pathogenic']
+
+export const CLINVAR_REVIEW_STATUS_STARS = {
+ 'no_assertion_criteria_provided': 0,
+ 'criteria_provided,_conflicting_interpretations': 1,
+ 'criteria_provided,_single_submitter': 1,
+ 'criteria_provided,_multiple_submitters,_no_conflicts': 2,
+ 'reviewed_by_expert_panel': 3
+}
diff --git a/src/lib/heatmap.ts b/src/lib/heatmap.ts
index 86a8f663..834a6daa 100644
--- a/src/lib/heatmap.ts
+++ b/src/lib/heatmap.ts
@@ -1,14 +1,59 @@
import * as d3 from 'd3'
+import $ from 'jquery'
+import _, { filter, last } from 'lodash'
+
import { AMINO_ACIDS, AMINO_ACIDS_BY_HYDROPHILIA } from './amino-acids.js'
+import { NUCLEOTIDE_BASES } from './nucleotides.js'
+
+type FieldGetter = ((d: HeatmapDatum) => T)
+type Getter = () => T
+type Accessor = (value?: T) => T | Self
+
+export const DEFAULT_MINIMUM_COLOR = '#3F51B5'
+export const DEFAULT_PIVOT_COLOR = '#FFFFFF'
+export const DEFAULT_MAXIMUM_COLOR = '#B00020'
+
+const LABEL_SIZE = 10
+const LEGEND_SIZE = 75
/** Codes used in the right part of a MaveHGVS-pro string representing a single variation in a protein sequence. */
-const MAVE_HGVS_PRO_CHANGE_CODES = [
+export const MAVE_HGVS_PRO_CHANGE_CODES = [
{ codes: { single: '=' } }, // Synonymous AA variant
{ codes: { single: '*', triple: 'TER' } }, // Stop codon
{ codes: { single: '-', triple: 'DEL' } } // Deletion
]
-interface HeatmapRowSpecification {
+export const HEATMAP_NUCLEOTIDE_ROWS: HeatmapRowSpecification[] = [
+ ...NUCLEOTIDE_BASES.map((ntCode) => ({ code: ntCode.codes.single, label: ntCode.codes.single }))
+]
+
+/** List of single-character codes for the heatmap's rows, from bottom to top. */
+export const HEATMAP_AMINO_ACID_ROWS: HeatmapRowSpecification[] = [
+ { code: '=', label: '=', cssClass: 'mave-heatmap-y-axis-tick-label-lg' },
+ { code: '*', label: '\uff0a' },
+ { code: '-', label: '-', cssClass: 'mave-heatmap-y-axis-tick-label-lg' },
+ ...AMINO_ACIDS_BY_HYDROPHILIA.map((aaCode) => ({ code: aaCode, label: aaCode }))
+]
+
+/**
+ * Margins of the heatmap content inside the SVG, expressed in screen units (pixels).
+*/
+export interface HeatmapMargins {
+ bottom: number
+ left: number
+ right: number
+ top: number
+}
+
+/**
+ * Sizes of the heatmap nodes, expressed in screen units (pixels).
+*/
+export interface HeatmapNodeSize {
+ width: number,
+ height: number
+}
+
+export interface HeatmapRowSpecification {
/** A single-character amino acid code or single-character code from MAVE_HGVS_PRO_CHANGE_CODES. */
code: string
/** The tick mark label text to display for this change, which is usually the same as the code. */
@@ -17,13 +62,873 @@ interface HeatmapRowSpecification {
cssClass?: string
}
-/** List of single-character codes for the heatmap's rows, from bottom to top. */
-export const HEATMAP_ROWS: HeatmapRowSpecification[] = [
- { code: '=', label: '=', cssClass: 'mave-heatmap-y-axis-tick-label-lg' },
- { code: '*', label: '\uff0a' },
- { code: '-', label: '-', cssClass: 'mave-heatmap-y-axis-tick-label-lg' },
- ...AMINO_ACIDS_BY_HYDROPHILIA.map((aaCode) => ({ code: aaCode, label: aaCode }))
-]
+export type HeatmapScores = any
+export type HeatmapDatum = any
+export type MappedDatum = { [key: number]: HeatmapDatum }
+
+/**
+ * The heatmap content. This consists of a mapping of rows which contain a list of ordered column contents.
+*/
+export interface HeatmapContent {
+ [key: number]: MappedDatum
+ columns?: number
+}
+
+export interface Heatmap {
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Methods
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // Chart lifecycle methods
+ destroy: () => void
+ render: (container: HTMLElement) => Heatmap
+ refresh: () => Heatmap
+ resize: () => Heatmap
+
+ // Selection management
+ clearSelection: () => void
+ selectDatum: (d: HeatmapDatum) => void
+ selectDatumByIndex: (x: number, y: number) => void
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Accessors
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ /** Data (heatmap content) */
+ data: Accessor
+ content: Accessor
+ rowClassifier: Accessor<((d: HeatmapDatum) => number[]) | null, Heatmap>
+ colorClassifier: Accessor<((d: HeatmapDatum) => number | d3.Color), Heatmap>
+ datumSelected: Accessor<((d: HeatmapDatum) => void) | null, Heatmap>
+ excludeDatum: Accessor<((d: HeatmapDatum) => boolean), Heatmap>
+
+ // Data fields
+ valueField: Accessor, Heatmap>
+ xCoordinate: Accessor, Heatmap>
+ yCoordinate: Accessor, Heatmap>
+ tooltipHtml: Accessor<((
+ datum: HeatmapDatum | null,
+ ) => string | null) | null, Heatmap>
+
+ // Layout
+ margins: Accessor
+ rows: Accessor
+ nodeSize: Accessor
+
+ // Color
+ lowerBoundColor: Accessor
+ pivotColor: Accessor
+ upperBoundColor: Accessor
+
+ // Axis controls
+ drawX: Accessor
+ drawY: Accessor
+
+ // Legend controls
+ legendTitle: Accessor
+ drawLegend: Accessor
+ alignViaLegend: Accessor
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Getters
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // Selection
+ selectedDatum: Getter
+
+ // Container
+ container: Getter
+
+ // Data
+ heatmapContent: Getter
+ filteredData: Getter
+ lowerBound: Getter
+ upperBound: Getter
+
+ // Layout
+ width: Getter
+ height: Getter
+
+ // Color scale
+ colorScale: Getter | null>
+
+}
+
+export default function makeHeatmap(): Heatmap {
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Read/write properties
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // Data fields
+ let valueField: FieldGetter = (d) => d as number
+ let xCoordinate: FieldGetter = (d) => d as number
+ let yCoordinate: FieldGetter = (d) => d as number
+ let tooltipHtml: ((
+ datum: HeatmapDatum | null,
+ ) => string | null) | null = null
+
+ // Data
+ let data: HeatmapDatum[] = []
+ let rowClassifier: ((d: HeatmapDatum) => number[]) | null = null
+ let colorClassifier: ((d: HeatmapDatum) => number | d3.Color) = valueField
+ let datumSelected: ((d: HeatmapDatum) => void) | null = null
+ let excludeDatum: ((d: HeatmapDatum) => boolean) = (d) => false as boolean
+
+ // Layout
+ let margins: HeatmapMargins = { top: 20, right: 20, bottom: 30, left: 20 }
+ let nodeSize: HeatmapNodeSize = { width: 20, height: 20}
+ let rows: HeatmapRowSpecification[] = HEATMAP_AMINO_ACID_ROWS
+
+ // Colors
+ let lowerBoundColor: string = DEFAULT_MINIMUM_COLOR
+ let pivotColor: string = DEFAULT_PIVOT_COLOR
+ let upperBoundColor: string = DEFAULT_MAXIMUM_COLOR
+
+ // Axis controls
+ let drawX: boolean = true
+ let drawY: boolean = true
+
+ // Legend controls
+ let legendTitle: string | null = null
+ let drawLegend: boolean = true
+ let alignViaLegend: boolean = false
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Read-only properties
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // Content
+ let content: HeatmapContent = {
+ columns: undefined
+ }
+ let filteredData: HeatmapDatum[] = []
+ let lowerBound: number | null = null
+ let upperBound: number | null = null
+
+ // Selection
+ let selectedDatum: HeatmapDatum | null = null
+
+ // Container
+ let _container: HTMLElement | null = null
+
+ // Layout
+ let height: number | null = null
+ let width: number | null = null
+
+ // Color scale
+ let colorScale: d3.ScaleLinear | null = null
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Internal properties
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // Hovering
+ let hoverDatum: HeatmapDatum | null = null
+
+ // Layout
+
+ /** Margins of the heatmap itself, after leaving space for other drawn elements. */
+ let effectiveMargins: HeatmapMargins = { top: 0, right: 0, bottom: 0, left: 0 }
+ const padding: number = .1
+
+ // D3 selections containing DOM elements
+ let svg: d3.Selection | null = null
+ let hoverTooltip: d3.Selection | null = null
+ let selectionTooltip: d3.Selection | null = null
+
+ // Scales
+ const xScale: d3.ScaleBand = d3.scaleBand()
+ const yScale: d3.ScaleBand = d3.scaleBand()
+
+ // Index bounds
+ let idxLowerBound: number | null = null
+ let idxUpperBound: number | null = null
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Data series & row/column preparation
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const prepareData = () => {
+ for (let datum of data) {
+ datum.isVisible = !excludeDatum(datum)
+
+ content[xCoordinate(datum)] = content[xCoordinate(datum)] || {}
+ content[xCoordinate(datum)][yCoordinate(datum)] = datum
+
+ if (!isNaN(valueField(datum))) {
+ lowerBound = lowerBound ? Math.min(lowerBound, valueField(datum)) : valueField(datum)
+ upperBound = upperBound ? Math.max(upperBound, valueField(datum)) : valueField(datum)
+ }
+
+ idxLowerBound = idxLowerBound ? Math.min(idxLowerBound, xCoordinate(datum)) : xCoordinate(datum)
+ idxUpperBound = idxUpperBound ? Math.max(idxUpperBound, xCoordinate(datum)) : xCoordinate(datum)
+
+ if (datum.isVisible) {
+ filteredData.push(datum)
+ }
+ }
+ content.columns = Object.keys(content).length - 1
+ buildColorScale()
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Coloring
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const color = function (d: HeatmapDatum) {
+ let classification: number | d3.Color = colorClassifier(d)
+ return typeof classification === "number" ? (colorScale ? colorScale(classification) : null) : classification
+ }
+
+ const buildColorScale = function () {
+ const imputedDomain = [
+ (lowerBound ? lowerBound : 0),
+ ((lowerBound ? lowerBound : 0) + (upperBound ? upperBound : 1)) / 2,
+ (upperBound ? upperBound : 1),
+ ]
+ const imputedRange = [lowerBoundColor, pivotColor, upperBoundColor]
+ colorScale = d3.scaleLinear()
+ .domain(imputedDomain)
+ .range(imputedRange)
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Clicking
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const click = function (event: MouseEvent, d: HeatmapDatum) {
+ const target = event.target
+ refreshSelectedDatum(d, true)
+
+ if (datumSelected) {
+ datumSelected(selectedDatum)
+ }
+
+ if (target instanceof Element) {
+ updateSelectionTooltipAfterRefresh()
+ }
+
+ // Hide the hover tooltip.
+ hideTooltip(hoverTooltip)
+ }
+
+ const refreshSelectedDatum = function (d: HeatmapDatum | null, unset: boolean) {
+ if (selectedDatum !== null) {
+ hideHighlight(selectedDatum)
+ }
+
+ // If unset is passed, de-select the selection if it is the same as the refreshed datum.
+ if (selectedDatum === d && unset) {
+ selectedDatum = null
+ } else {
+ selectedDatum = d
+ }
+
+ if (selectedDatum) {
+ showHighlight(selectedDatum)
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Scrolling
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const scrollToDatum = function (d: HeatmapDatum) {
+ if (_container) {
+ const scrollValue = xScale(xCoordinate(d)) + strokeWidth(d) / 2
+
+ // Only scroll if the variant is not in view.
+ const variantIsInView = _container.parentElement.scrollLeft < scrollValue && _container.clientWidth + _container.parentElement.scrollLeft > scrollValue
+ if (!variantIsInView) {
+ _container.parentElement.scrollLeft = scrollValue
+ }
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Hovering
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const mouseover = (event: MouseEvent, d: HeatmapDatum) => {
+ const target = event.target
+ refreshHoverDatum(d)
+
+ if (target instanceof Element) {
+ showTooltip(hoverTooltip, hoverDatum)
+ }
+ }
+
+ const mousemove = (event: MouseEvent) => {
+ if (hoverTooltip) {
+ // Move tooltip to be 30px to the right of the pointer.
+ hoverTooltip
+ .style('left', (d3.pointer(event, document.body)[0] + 30) + 'px')
+ .style('top', (d3.pointer(event, document.body)[1]) + 'px')
+ }
+ }
+
+ const mouseleave = (event: MouseEvent, d: HeatmapDatum) => {
+ refreshHoverDatum(null)
+
+ // Hide the tooltip and the highlight.
+ hideTooltip(hoverTooltip)
+ }
+
+ const refreshHoverDatum = function (d: HeatmapDatum | null) {
+ // Don't hide the highlight if we happen to be hovering over the selectedDatum.
+ if (selectedDatum !== hoverDatum && hoverDatum) {
+ hideHighlight(hoverDatum)
+ }
+ hoverDatum = d
+ if (hoverDatum) {
+ showHighlight(hoverDatum)
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Legend Management
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // todo
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Tooltip management
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const renderTooltips = () => {
+ hoverTooltip = d3.select(document.body)
+ .append('div')
+ .style('display', 'none')
+ .attr('class', 'heatmap-tooltip')
+ .style('background-color', '#fff')
+ .style('border', 'solid')
+ .style('border-width', '2px')
+ .style('border-radius', '5px')
+ .style('color', '#000')
+ .style('padding', '5px')
+ .style('z-index', 2001)
+
+ selectionTooltip = d3.select(_container)
+ .append('div')
+ .style('display', 'none')
+ .attr('class', 'heatmap-selection-tooltip')
+ .style('background-color', 'white')
+ .style('border', 'solid')
+ .style('border-width', '2px')
+ .style('border-radius', '5px')
+ .style('color', '#000')
+ .style('padding', '5px')
+ .style('position', 'relative')
+ .style('width', 'fit-content')
+ .style('z-index', 1)
+ }
+
+ const showTooltip = (
+ tooltip: d3.Selection | null,
+ datum: HeatmapDatum | null
+ ) => {
+ if (datum) {
+ if (tooltipHtml) {
+ const html = tooltipHtml(
+ datum
+ )
+
+ if (html && tooltip) {
+ tooltip.html(html)
+ tooltip.style('display', 'block')
+ }
+ }
+ }
+ }
+
+ const hideTooltip = (tooltip: d3.Selection | null) => {
+ if (tooltip) {
+ tooltip.style('display', 'none')
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Selection tooltip management
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const positionSelectionTooltip = function () {
+ if (selectionTooltip && selectedDatum && _container) {
+ scrollToDatum(selectedDatum)
+
+ // The left and top anchor positions for this tooltip.
+ const left = xScale(xCoordinate(selectedDatum)) || 0
+ const top = yScale(yCoordinate(selectedDatum)) || 0
+
+ // The tooltip dimensions.
+ const tooltipHeight = selectionTooltip.node()?.getBoundingClientRect().height || 0
+ const tooltipWidth = selectionTooltip.node()?.getBoundingClientRect().width || 0
+
+ // How far to the left we have scrolled the parent element of the heatmap container
+ const scrollPosition = _container.parentElement?.scrollLeft || 0
+
+ // Set the bottom margin equal to the total height of the tooltip. This ensures the tooltip
+ // does not take up any vertical space in the document, despite being rentered with relative position.
+ selectionTooltip
+ .style('margin-bottom', -tooltipHeight + "px")
+
+ // TODO: Bug- Drawing the selection tooltip makes the SVG scroll container add tooltipHeight worth of height
+ // to the scroll container.
+
+ // Show the tooltip to the left of the datum node if it would overflow from the right side of the heatmap container.
+ if (left + effectiveMargins.left + (1.5 * nodeSize.width) + (tooltipWidth) > scrollPosition + _container.clientWidth) {
+ selectionTooltip
+ // When drawing the tooltip to the right of the node, the width of the tooltip influences how far to move it.
+ .style('left', left - (0.5 * tooltipWidth) - (nodeSize.width) + (0.5 * strokeWidth(true)) + 'px')
+ } else {
+ selectionTooltip
+ .style('left', left + effectiveMargins.left + (1.5 * nodeSize.width) + (0.5 * strokeWidth(true)) + 'px')
+ }
+
+ // Show the tooltip under the datum node if it would overflow from the top of the heatmap container.
+ if (yCoordinate(selectedDatum) < rows.length / 4) {
+ selectionTooltip
+ .style('top', null)
+ .style('bottom', _container.clientHeight - top - tooltipHeight + (0.5 * strokeWidth(true)) + 'px')
+ } else {
+ selectionTooltip
+ .style('top', -(_container.clientHeight - top) + nodeSize.height + (0.5 * strokeWidth(true)) + 'px')
+ .style('bottom', null)
+ }
+ }
+ }
+
+ const updateSelectionTooltipAfterRefresh = () => {
+ if (selectedDatum) {
+ // Construct, then position, the click tooltip. If we do this the other way, our positioning
+ // function won't see the correct tooltip dimensions.
+ showTooltip(selectionTooltip, selectedDatum)
+ positionSelectionTooltip()
+ } else {
+ hideTooltip(selectionTooltip)
+ }
+ }
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Bin highlighting for selections and hovering
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const stroke = (draw: boolean) => {
+ return draw ? '#000' : 'none'
+ }
+
+ const strokeWidth = function(draw: boolean) {
+ return draw ? 2 : 0;
+ }
+
+ const hideHighlight = (d: HeatmapDatum) => {
+ if (svg) {
+ svg.select(`g.heatmap-nodes`).selectAll(`rect.node-${xCoordinate(d)}-${yCoordinate(d)}`)
+ .style('stroke', stroke(false))
+ .style('stroke-width', strokeWidth(false))
+ }
+ }
+
+ const showHighlight = (d: HeatmapDatum) => {
+ if (svg) {
+ svg.select(`g.heatmap-nodes`).selectAll(`rect.node-${xCoordinate(d)}-${yCoordinate(d)}`)
+ .style('stroke', stroke(true))
+ .style('stroke-width', strokeWidth(true))
+ }
+ }
+
+ const chart: Heatmap = {
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Chart lifecyle methods
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ destroy: () => {
+ if (svg) {
+ svg.remove()
+ svg = null
+ }
+ if (hoverTooltip) {
+ hoverTooltip.remove()
+ hoverTooltip = null
+ }
+ if (selectionTooltip) {
+ selectionTooltip.remove()
+ selectionTooltip = null
+ }
+ data = []
+ filteredData = []
+ content = {columns: undefined}
+ },
+
+ render: (container: HTMLElement) => {
+ _container = container
+
+ if (_container) {
+ svg = d3.select(_container)
+ .html(null)
+ .append('svg')
+ svg.append('defs')
+ // Draw the legend after applying the margins.
+ const legendGroup = svg.append('g')
+ .attr('class', 'heatmap-legend')
+ .attr('transform', `translate(${margins.left},${margins.top})`)
+ legendGroup.append('g')
+ .attr('class', 'heatmap-vertical-color-legend')
+ const mainGroup = svg.append('g')
+ .attr('class', 'heatmap-main')
+ mainGroup.append('g')
+ .attr('class', 'heatmap-bottom-axis')
+ mainGroup.append('g')
+ .attr('class', 'heatmap-left-axis')
+ mainGroup.append('g')
+ .attr('class', 'heatmap-y-axis-tick-labels')
+ mainGroup.append('g')
+ .attr('class', 'heatmap-hovers')
+ mainGroup.append('g')
+ .attr('class', 'heatmap-nodes')
+
+ if (alignViaLegend || drawLegend) {
+ // Update the heatmap effective margins to take the legend into account.
+ effectiveMargins = {
+ ...margins,
+ left: margins.left + LEGEND_SIZE
+ }
+ }
+
+ // Main group's margins must include the legend.
+ svg.select('g.heatmap-main')
+ .attr('transform', `translate(${effectiveMargins.left}, ${effectiveMargins.top})`)
+
+ } else {
+ svg = null
+ }
+
+ renderTooltips()
+ chart.resize()
+
+ return chart
+ },
+
+ refresh: () => {
+ if (_container && svg) {
+ chart.resize()
+ prepareData()
+
+ if (drawLegend) {
+ const legend = d3.select('g.heatmap-vertical-color-legend')
+ .attr('width', LEGEND_SIZE)
+ .attr('height', height)
+
+ verticalColorLegend(
+ legend, {
+ color: colorScale,
+ title: legendTitle,
+ height: height,
+ marginTop: 0,
+ })
+ }
+
+ // Set the Y scale. We are placing all row content starting at screen position 0 and continuing to the heatmap height.
+ yScale.range([0, height]).domain(_.range(0, rows.length)).padding(padding)
+
+ // Set the X scale. We are placing idxLowerBound to idxUpperBound (all heatmap content) starting at screen position 0 and continuing to the effective heatmap width.
+ xScale.range([0, width - effectiveMargins.left]).domain((idxLowerBound && idxUpperBound ? _.range(idxLowerBound, idxUpperBound + 1) : [])).padding(padding)
+
+ // Refresh the axes.
+ if (drawX) {
+ svg.select('g.heatmap-bottom-axis')
+ .style('font-size', 15)
+ .attr('transform', `translate(0,${height})`)
+ // @ts-ignore
+ .call(d3.axisBottom(xScale).ticks(0))
+ .select('.domain').remove()
+
+ // Make all even-numbered x-axis labels invisible so they don't overlap at n > 100.
+ svg.select('g.heatmap-bottom-axis').selectAll('g.tick')
+ .attr('class', (n) => (n % 2 === 0) ? 'heatmap-x-axis-invisible' : '')
+ }
+ if (drawY) {
+ svg.select('g.heatmap-y-axis-tick-labels')
+ // @ts-ignore
+ // Get the row's amino acid code or variation symbol
+ .call(d3.axisLeft(yScale)
+ .tickSize(0)
+ .tickFormat((n) => rows[rows.length - 1 - n].label)
+ )
+ .select('.domain').remove()
+
+ // Apply row-specific CSS classes to Y-axis tick mark labels.
+ svg.selectAll('g.heatmap-y-axis-tick-labels g.tick')
+ .attr('class', (n) => rows[rows.length - 1 - n].cssClass || '')
+ }
+
+
+
+ // Refresh each heatmap node.
+ const chartedDatum = svg.select('g.heatmap-nodes').selectAll('rect').data(filteredData, (d) => d)
+ chartedDatum.exit().remove()
+ chartedDatum.enter()
+ .append('rect')
+ .attr('class', d => `node-${xCoordinate(d)}-${yCoordinate(d)}`)
+ .attr('rx', 4)
+ .attr('ry', 4)
+ .style('cursor', 'pointer')
+ .style('opacity', 0.8)
+ .on('mouseover', mouseover)
+ .on('mousemove', mousemove)
+ .on('mouseleave', mouseleave)
+ .on('click', click)
+ .merge(chartedDatum)
+ .attr('x', d => xScale(xCoordinate(d)))
+ .attr('y', d => yScale(yCoordinate(d)))
+ // bandwidth is directly proportional to the controllable nodeHeight and nodeWidth properties.
+ .attr('width', xScale.bandwidth())
+ .attr('height', yScale.bandwidth())
+ .style('fill', d => color(d))
+ .style('stroke-width', d => strokeWidth(d === selectedDatum))
+ .style('stroke', d => stroke(d === selectedDatum))
+ }
+
+ updateSelectionTooltipAfterRefresh()
+ return chart
+ },
+
+ resize: () => {
+ if (_container && svg) {
+ // Implied height/width bbased on the provided node sizes and heatmap contents.
+ const heatmapCalculatedHeight = nodeSize.height * rows.length
+ const heatmapCalculatedWidth = nodeSize.width * (content.columns ? content.columns : 0)
+
+ // Total width/height, including all margins and additional elements.
+ const heatmapTotalWidth = ((margins.left + margins.right) + (drawLegend || alignViaLegend ? LEGEND_SIZE : 0) + heatmapCalculatedWidth) * (1 + padding)
+ const heatmapTotalHeight = ((margins.top + margins.bottom) + heatmapCalculatedHeight) * (1 + padding)
+
+ // Total width/height, less any provided margins.
+ const drawableHeight = heatmapTotalHeight - margins.top - margins.bottom
+ const drawableWidth = heatmapTotalWidth - margins.left - margins.right
+
+ // Use these properties to draw heatmap elements.
+ height = drawableHeight
+ width = drawableWidth
+
+ svg.attr('height', heatmapTotalHeight)
+ .attr('width', heatmapTotalWidth)
+ }
+ return chart
+ },
+
+ clearSelection: () => {
+ hideHighlight(selectedDatum)
+ selectedDatum = null
+ hideTooltip(selectionTooltip)
+ },
+
+ selectDatumByIndex: (datumRowIndex: number, datumColumnIndex: number) => {
+ refreshSelectedDatum(content[datumRowIndex][datumColumnIndex], false)
+ updateSelectionTooltipAfterRefresh()
+ },
+
+ selectDatum: (d: HeatmapDatum) => {
+ refreshSelectedDatum(d, false)
+ updateSelectionTooltipAfterRefresh()
+ },
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Accessors
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ data: (value?: HeatmapDatum[]) => {
+ if (value === undefined) {
+ return data
+ }
+ data = value
+ prepareData()
+ return chart
+ },
+
+ rows: (value?: HeatmapRowSpecification[]) => {
+ if (value === undefined) {
+ return data
+ }
+ rows = value
+ return chart
+ },
+
+ rowClassifier: (value?: ((d: HeatmapDatum) => number[]) | null) => {
+ if (value === undefined) {
+ return rowClassifier
+ }
+ rowClassifier = value
+ return chart
+ },
+
+ colorClassifier: (value?: ((d: HeatmapDatum) => number | d3.Color)) => {
+ if (value === undefined) {
+ return colorClassifier
+ }
+ colorClassifier = value
+ return chart
+ },
+
+ datumSelected: (value?: ((d: HeatmapDatum) => void) | null) => {
+ if (value === undefined) {
+ return datumSelected
+ }
+ datumSelected = value
+ return chart
+ },
+
+ excludeDatum: (value?: ((d: HeatmapDatum) => boolean)) => {
+ if (value === undefined) {
+ return excludeDatum
+ }
+ excludeDatum = value
+ return chart
+ },
+
+ valueField: (value?: FieldGetter) => {
+ if (value === undefined) {
+ return valueField
+ }
+ valueField = value
+ return chart
+ },
+
+ xCoordinate: (value?: FieldGetter) => {
+ if (value === undefined) {
+ return xCoordinate
+ }
+ xCoordinate = value
+ return chart
+ },
+
+ yCoordinate: (value?: FieldGetter) => {
+ if (value === undefined) {
+ return yCoordinate
+ }
+ yCoordinate = value
+ return chart
+ },
+
+ tooltipHtml: (value?: ((
+ datum: HeatmapDatum | null,
+ ) => string | null) | null) => {
+ if (value === undefined) {
+ return tooltipHtml
+ }
+ tooltipHtml = value
+ return chart
+ },
+
+ margins: (value?: HeatmapMargins) => {
+ if (value === undefined) {
+ return margins
+ }
+ margins = value
+ return chart
+ },
+
+ nodeSize: (value?: HeatmapNodeSize) => {
+ if (value === undefined) {
+ return nodeSize
+ }
+ nodeSize = value
+ return chart
+ },
+
+ lowerBoundColor: (value?: string) => {
+ if (value === undefined) {
+ return lowerBoundColor
+ }
+ lowerBoundColor = value
+ return chart
+ },
+
+ pivotColor: (value?: string) => {
+ if (value === undefined) {
+ return pivotColor
+ }
+ pivotColor = value
+ return chart
+ },
+
+ upperBoundColor: (value?: string) => {
+ if (value === undefined) {
+ return upperBoundColor
+ }
+ upperBoundColor = value
+ return chart
+ },
+
+ drawX: (value?: boolean) => {
+ if (value === undefined) {
+ return drawX
+ }
+
+ drawX = value
+ return chart
+ },
+
+ drawY: (value?: boolean) => {
+ if (value === undefined) {
+ return drawY
+ }
+
+ drawY = value
+ return chart
+ },
+
+ legendTitle: (value?: string | null) => {
+ if (value === undefined) {
+ return legendTitle
+ }
+
+ legendTitle = value
+ return chart
+ },
+
+ drawLegend: (value?: boolean) => {
+ if (value === undefined) {
+ return drawLegend
+ }
+
+ drawLegend = value
+ return chart
+ },
+
+ alignViaLegend: (value?: boolean) => {
+ if (value === undefined) {
+ return alignViaLegend
+ }
+
+ alignViaLegend = value
+ return chart
+ },
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Getters
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ selectedDatum: () => selectedDatum,
+
+ container: () => _container,
+
+ height: () => height,
+
+ width: () => width,
+
+ colorScale: () => colorScale,
+
+ lowerBound: () => lowerBound,
+
+ upperBound: () => upperBound,
+
+ filteredData: () => filteredData,
+
+ content: () => content,
+ }
+
+ return chart
+}
/**
* Given a MaveHGVS-pro amino acid code or code representing deletion, synonmyous variation, or stop codon, return the
@@ -59,9 +964,24 @@ export function singleLetterAminoAcidOrHgvsCode(aaCodeOrChange: string): string
* variation (=), stop codon (*), or deletion (- or del).
* @returns The heatmap row number, from 0 (the bottom row) to 22 (the top row).
*/
-export function heatmapRowForVariant(aaCodeOrChange: string): number | null {
+export function heatmapRowForProteinVariant(aaCodeOrChange: string): number | null {
const singleLetterCode = singleLetterAminoAcidOrHgvsCode(aaCodeOrChange)
- const ranking = singleLetterCode ? HEATMAP_ROWS.findIndex((rowSpec) => rowSpec.code == singleLetterCode) : null
+ const ranking = singleLetterCode ? HEATMAP_AMINO_ACID_ROWS.findIndex((rowSpec) => rowSpec.code == singleLetterCode) : null
+ return (ranking != null && ranking >= 0) ? ranking : null
+}
+
+
+/**
+ * Given a MaveHGVS-pro amino acid code or code representing deletion, synonmyous variation, or stop codon, return the
+ * heatmap row number on which a single-AA variant should be displayed.
+ *
+ * @param ntCodeOrChange A one-character code representing a nucleotide base or the result of a variation at a
+ * single locus in a nucleotide sequence.
+ * @returns The heatmap row number, from 0 (the bottom row) to 3 (the top row).
+ */
+export function heatmapRowForNucleotideVariant(ntCodeOrChange: string): number | null {
+ const singleLetterCode = ntCodeOrChange.toUpperCase()
+ const ranking = singleLetterCode ? HEATMAP_NUCLEOTIDE_ROWS.findIndex((rowSpec) => rowSpec.code == singleLetterCode) : null
return (ranking != null && ranking >= 0) ? ranking : null
}
@@ -104,18 +1024,18 @@ function ramp(color: Function, n = 256) {
export function verticalColorLegend(containerSelection: d3.Selection, {
color = d3.scaleSequential(d3.interpolateRdBu).domain([0, 1]),
title = "Legend",
- tickSize = 6,
+ tickSize = 5,
width = 36 + tickSize,
height = 100,
marginTop = 12,
- marginRight = 10,
+ marginRight = 5,
marginBottom = 0,
- marginLeft = 10 + tickSize,
+ marginLeft = 15 + tickSize,
ticks = height / 64,
tickFormat = null,
tickValues = null,
} = {}) {
- let tickAdjust = (g: any) => g.selectAll(".tick line").attr("x1", width - marginLeft - marginRight);
+ let tickAdjust = (g: any) => g.selectAll(".tick line").attr("x1", width - marginLeft - marginRight + tickSize);
// Continuous color scale
const n = Math.min(color.domain().length, color.range().length);
@@ -132,21 +1052,22 @@ export function verticalColorLegend(containerSelection: d3.Selection g.select(".domain").remove())
.call((g) => g.append("text")
- .attr("x", marginLeft)
- .attr("y", marginTop - 5) // draw the title 5px above the color bar
+ .attr("x", 0)
+ .attr("y", marginTop)
.attr("fill", "#000000")
- .attr("text-anchor", "end")
- .attr("font-weight", "bold")
+ .attr("text-anchor", "middle")
.attr("class", "title")
+ .attr('font-size', LABEL_SIZE)
+ .attr('transform', `translate(${-(width - marginLeft / 2)}, ${height / 2}) rotate(-90)`)
.text(title));
return containerSelection.node();
diff --git a/src/lib/histogram.ts b/src/lib/histogram.ts
new file mode 100644
index 00000000..8b0f6832
--- /dev/null
+++ b/src/lib/histogram.ts
@@ -0,0 +1,1032 @@
+import * as d3 from 'd3'
+import $ from 'jquery'
+import _ from 'lodash'
+
+type FieldGetter = ((d: HistogramDatum) => T) | string
+type Getter = () => T
+type Accessor = (value?: T) => T | Self
+
+export const DEFAULT_RANGE_COLOR = '#333333'
+export const DEFAULT_SERIES_COLOR = '#333333'
+const LABEL_SIZE = 10
+
+/**
+ * Margins of the histogram content inside the SVG, expressed in screen units (pixels).
+ *
+ * This should include space for the color scale legend.
+ */
+export interface HistogramMargins {
+ bottom: number
+ left: number
+ right: number
+ top: number
+}
+
+export interface HistogramSerieOptions {
+ title?: string
+ color: string // TODO Make this optional by providing default colors.
+}
+
+interface HistogramSerie {
+ /** Bins, which are an array of HistogramDatum with additional x0 and x1 properties. */
+ bins: d3.Bin[]
+
+ /** The minimum of all the bins' x0 values. */
+ x0: number | null
+
+ /** The maximum of all the bins' x1 values. */
+ x1: number | null
+
+ /** The maximum number of data points in any bin. */
+ maxBinSize: number
+
+ /** A list of points describing the series bars' silhouette. */
+ line: [number, number][],
+
+ options: HistogramSerieOptions
+}
+
+export type HistogramDatum = any
+
+export interface HistogramBin {
+ x0: number
+ x1: number
+ yMax: number
+
+ /**
+ * Bins at this location belonging to each series.
+ *
+ * seriesBins[N] is the bin at this location belonging to series[N].
+ */
+ seriesBins: d3.Bin[]
+}
+
+export interface HistogramRange {
+ min: number | null
+ max: number | null
+ title: string | null
+ color: string | null
+}
+
+export interface Histogram {
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Methods
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // Chart lifecycle methods
+ destroy: () => void
+ render: (container: HTMLElement) => Histogram
+ refresh: () => Histogram
+ resize: () => Histogram
+
+ // Selection management
+ clearSelection: () => void
+ selectBin: (binIndex: number) => void
+ selectDatum: (d: HistogramDatum) => void
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Accessors
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ /** Data (histogram bin contents) */
+ data: Accessor
+ seriesOptions: Accessor
+ seriesClassifier: Accessor<((d: HistogramDatum) => number[]) | null, Histogram>
+ ranges: Accessor
+ numBins: Accessor
+
+ // Data fields
+ valueField: Accessor, Histogram>
+ tooltipHtml: Accessor<((
+ datum: HistogramDatum | null,
+ bin: HistogramBin | null,
+ seriesContainingDatum: HistogramSerieOptions[],
+ allSeries: HistogramSerieOptions[]
+ ) => string | null) | null, Histogram>
+
+ // Layout
+ margins: Accessor
+
+ // Labels
+ title: Accessor
+ leftAxisLabel: Accessor
+ bottomAxisLabel: Accessor
+ legendNote: Accessor
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Getters
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // Selection
+ selectedBin: Getter
+ selectedDatum: Getter
+
+ // Container
+ container: Getter
+
+ // Layout
+ width: Getter
+ height: Getter
+}
+
+export default function makeHistogram(): Histogram {
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Read/write properties
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // Data
+ let data: HistogramDatum[] = []
+ let seriesOptions: HistogramSerieOptions[] | null = null
+ let seriesClassifier: ((d: HistogramDatum) => number[]) | null = null
+ let ranges: HistogramRange[] = []
+ let numBins = 30
+
+ // Data fields
+ let valueField: FieldGetter = (d) => d as number
+ let tooltipHtml: ((
+ datum: HistogramDatum | null,
+ bin: HistogramBin | null,
+ seriesContainingDatum: HistogramSerieOptions[],
+ allSeries: HistogramSerieOptions[]
+ ) => string | null) | null = null
+
+ // Layout
+ let margins: HistogramMargins = {top: 20, right: 20, bottom: 30, left: 20}
+
+ // Title
+ let title: string | null = null
+ let leftAxisLabel: string | null = null
+ let bottomAxisLabel: string | null = null
+ let legendNote: string | null = null
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Read-only properties
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // Selection
+ let selectedDatum: HistogramDatum | null = null
+ let selectedBin: HistogramBin | null = null
+
+ // Container
+ let _container: HTMLElement | null = null
+
+ // Layout
+ let height: number = 100
+ let width: number = 100
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Internal properties
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ // Data
+ let series: HistogramSerie[] = []
+ let bins: HistogramBin[] = []
+
+ // Hovering
+ let hoverBin: HistogramBin | null = null
+
+ // Layout
+
+ /** Margins of the actual historgram itself after leaving space for labels */
+ let effectiveMargins: HistogramMargins = {top: 0, right: 0, bottom: 0, left: 0}
+
+ // D3 selections containing DOM elements
+ let svg: d3.Selection | null = null
+ let tooltip: d3.Selection | null = null
+ let selectionTooltip: d3.Selection | null = null
+
+ // Scales
+ const xScale = d3.scaleLinear()
+ const yScale = d3.scaleLinear()
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Data series & bin preparation
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const prepareData = () => {
+ // Bin all the data, regardless of what series each datum belongs to.
+ const overallBins = d3.bin().thresholds(numBins).value((d) => applyField(d, valueField))(data)
+ const thresholds = (overallBins.length > 0 ? [overallBins[0].x0, ...overallBins.map((bin) => bin.x1)] : [])
+ .filter((t) => t != null)
+ const domain: [number, number] = [thresholds[0] || 0, thresholds[thresholds.length - 1] || 0]
+
+ const classifier = seriesClassifier // Make this a const so that TypeScript will be certain it remains non-null.
+ if (seriesOptions && classifier) {
+ const binClassifier = d3.bin()
+ .domain(domain)
+ .thresholds(thresholds)
+ .value((d) => applyField(d, valueField))
+ series = seriesOptions.map((serieOptions, i) => ({
+ bins: binClassifier(data.filter((datum) => classifier(datum).includes(i))),
+ x0: null,
+ x1: null,
+ maxBinSize: 0,
+ line: [],
+ options: serieOptions
+ }))
+ } else {
+ series = [{
+ bins: overallBins,
+ x0: null,
+ x1: null,
+ maxBinSize: 0,
+ line: [],
+ options: seriesOptions?.[0] || {
+ color: '#999999'
+ }
+ }]
+ }
+
+ for (const serie of series) {
+ serie.x0 = serie.bins[0]?.x0 === undefined ? null : serie.bins[0].x0
+ // @ts-ignore - We protect against the return value of `_.last(serie.bins)` being undefined.
+ serie.x1 = _.last(serie.bins)?.x1 === undefined ? null : _.last(serie.bins).x1
+ serie.maxBinSize = Math.max(...serie.bins.map((bin) => bin.length))
+ if (serie.x0 !== null && serie.x1 !== null) {
+ serie.line.push([serie.x0, 0])
+ for (const bin of serie.bins) {
+ if (bin.x0 != null) {
+ serie.line.push([bin.x0, bin.length])
+ }
+ if (bin.x1 != null) {
+ serie.line.push([bin.x1, bin.length])
+ }
+ }
+ serie.line.push([serie.x1, 0])
+ }
+ }
+
+ bins = overallBins.map((bin, binIndex) => ({
+ x0: bin.x0 || 0,
+ x1: bin.x1 || 0,
+ yMax: Math.max(...series.map((serie, i) => serie.bins[binIndex].length)),
+ seriesBins: series.map((serie) => serie.bins[binIndex])
+ }))
+ }
+
+ const findBinIndex = (x: number) => {
+ // The lower threshold of a bin is inclusive, the upper exclusive: [X0, x1).
+ const index = bins.findIndex((bin) => bin.x0 <= x && x < bin.x1)
+ return (index == -1) ? null : index
+ }
+
+ function applyField(d: HistogramDatum, field: FieldGetter) {
+ return _.isString(field) ? (_.get(d, field) as T) : (field(d) as T)
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Hovering
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const mouseover = (event: MouseEvent, d: HistogramBin) => {
+ const target = event.target
+
+ hoverBin = d
+ refreshHighlighting()
+
+ if (target instanceof Element) {
+ // Show the mouseover tooltip, and hide the tooltip for any currently selected variant.
+ if (tooltip) {
+ showTooltip(tooltip, hoverBin, null)
+ }
+ hideSelectionTooltip()
+ }
+ }
+
+ const mousemove = (event: MouseEvent) => {
+ if (tooltip) {
+ // Move tooltip to be 50px to the right of the pointer.
+ tooltip
+ .style('left', (d3.pointer(event, document.body)[0] + 50) + 'px')
+ .style('top', (d3.pointer(event, document.body)[1]) + 'px')
+ }
+ }
+
+ const mouseleave = (event: MouseEvent, d: HistogramBin) => {
+ if (d == hoverBin) {
+ hoverBin = null
+ refreshHighlighting()
+ }
+
+ // Hide the tooltip and the highlight.
+ if (tooltip) {
+ tooltip.style('display', 'none')
+ }
+
+ // Show the selection tooltip, if there is a selection.
+ showSelectionTooltip()
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Tooltip management
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const renderTooltips = () => {
+ tooltip = d3.select(document.body)
+ .append('div')
+ .style('display', 'none')
+ .attr('class', 'histogram-tooltip')
+ .style('background-color', '#fff')
+ .style('border', 'solid')
+ .style('border-width', '2px')
+ .style('border-radius', '5px')
+ .style('color', '#000')
+ .style('padding', '5px')
+ .style('z-index', 2001)
+
+ selectionTooltip = d3.select(_container)
+ .append('div')
+ .style('display', 'none')
+ .attr('class', 'histogram-selection-tooltip')
+ .style('background-color', 'white')
+ .style('border', 'solid')
+ .style('border-width', '2px')
+ .style('border-radius', '5px')
+ .style('color', '#000')
+ .style('padding', '5px')
+ .style('position', 'relative')
+ .style('width', 'fit-content')
+ .style('z-index', 1)
+ }
+
+ const showTooltip = (
+ tooltip: d3.Selection,
+ bin: HistogramBin,
+ datum: HistogramDatum | null
+ ) => {
+ if (tooltipHtml) {
+ const seriesContainingDatum = datum ?
+ (series && seriesClassifier) ?
+ seriesClassifier(datum).map((seriesIndex) => series[seriesIndex])
+ : series[0] ? [series[0]] : []
+ : []
+ const html = tooltipHtml(
+ datum,
+ bin,
+ seriesContainingDatum.map((s) => s.options),
+ series ? series.map((s) => s.options) : []
+ )
+
+ if (html) {
+ tooltip.html(html)
+ tooltip.style('display', 'block')
+ }
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Selection tooltip management
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const showSelectionTooltip = () => {
+ if (selectionTooltip) {
+ // Don't show the tooltip if no bin is selected. (A bin can be selected without a datum, but not vice versa.)
+ if (!selectedBin) {
+ selectionTooltip.style('display', 'none')
+ } else {
+ showTooltip(selectionTooltip, selectedBin, selectedDatum)
+ positionSelectionTooltip()
+ }
+ }
+ }
+
+ const positionSelectionTooltip = function() {
+ if (selectionTooltip && selectedBin) {
+ const documentWidth = document.body.clientWidth
+
+ // Tooltip position relative to SVG, and therefore also relative to the offset parent, which should be _container
+ const left = xScale(selectedBin.x1) + effectiveMargins.left
+ let top = -(yScale(0) - yScale(selectedBin.yMax)) - effectiveMargins.bottom
+
+ selectionTooltip
+ // Add a small buffer area to the left side of the tooltip so it doesn't overlap with the bin.
+ .style('left', `${left + 5}px`)
+ // Ensure the tooltip doesn't extend outside of the histogram container.
+ .style('max-width', `${documentWidth - left}px`)
+
+ // Having set the max width, get the height and border.
+ const tooltipHeight = selectionTooltip.node()?.clientHeight || 0
+ const topBorderWidth = selectionTooltip.node()?.clientTop || 0
+
+ // Move the tooltip above the x-axis if it would have obscured it.
+ if (top > -(tooltipHeight + effectiveMargins.bottom)) {
+ top -= tooltipHeight
+ }
+
+ selectionTooltip
+ // Add a small buffer to the vertical placement of the tooltip so it doesn't overlap with the axis.
+ .style('top', `${top - 15}px`)
+ // A pretty silly workaround for the fact that this div is relatively positioned and would otherwise take up
+ // space in the document flow.
+ .style('margin-bottom', `${-height - (topBorderWidth * 2)}px`)
+ }
+ }
+
+ const hideSelectionTooltip = () => {
+ selectionTooltip?.style('display', 'none')
+ }
+
+ const updateSelectionAfterRefresh = () => {
+ if (selectedDatum) {
+ const value = applyField(selectedDatum, valueField)
+ const selectedBinIndex = findBinIndex(value)
+ selectedBin = selectedBinIndex == null ? null : bins[selectedBinIndex]
+ }
+ if (selectedBin) {
+ showSelectionTooltip()
+ } else {
+ hideSelectionTooltip()
+ }
+ }
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Bin highlighting for selections and hovering
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const hoverOpacity = (d: HistogramBin) => {
+ // Don't highlight the bin if no serie has any data in this bin.
+ if (!d.seriesBins.some((bin) => bin.length > 0)) {
+ return 0
+ }
+ // If the cursor is hovering over a bin, ignore the selection and just highlight the hovered bin.
+ if (hoverBin) {
+ return d == hoverBin ? 1 : 0
+ }
+ // Highlight the selected bin if there is one.
+ return d == selectedBin ? 1 : 0
+ }
+
+ const refreshHighlighting = () => {
+ if (svg) {
+ svg.selectAll('.histogram-hover-highlight')
+ .style('opacity', (d) => hoverOpacity(d as HistogramBin))
+ }
+ }
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Ranges
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ const rangePolygon = (range: HistogramRange, yMax: number) => {
+ const points = []
+ const {min: xMin, max: xMax} = visibleRange(range)
+ const yMin = yScale.domain()[0]
+
+ // Start at the top left.
+ points.push([xMin, yMax])
+
+ // Trace the contour formed by the baseline and the bins, between xMin and xMax
+ let x = xMin
+
+ // First trace any portion to the left of the bins.
+ if (bins.length == 0 || x < bins[0].x0) {
+ points.push([x, yMin]) // Bottom left, if outside all bins
+ if (bins.length > 0) {
+ x = Math.min(bins[0].x1, xMax)
+ points.push([x, yMin]) // Base of first bin, or end of range if entire range is to the left of all bins
+ }
+ }
+
+ // Trace the portion above bins.
+ const startBinIndex = findBinIndex(x)
+ const xMaxBinIndex = findBinIndex(xMax)
+ const endBinIndex = xMaxBinIndex == null ? bins.length - 1 : xMaxBinIndex
+ if (x < xMax && startBinIndex != null) {
+ for (let binIndex = startBinIndex; binIndex <= endBinIndex; binIndex++) {
+ const bin = bins[binIndex]
+ points.push([x, bin.yMax])
+ x = Math.min(bin.x1, xMax)
+ points.push([x, bin.yMax])
+ }
+ }
+
+ // Trace any portion to the right of the bins.
+ if (x < xMax) {
+ points.push([x, yMin])
+ points.push([xMax, yMin])
+ }
+
+ // End at the top right.
+ points.push([xMax, yMax])
+
+ return points
+ }
+
+ const visibleRange = (range: HistogramRange) => {
+ return {
+ min: range.min == null ? xScale.domain()[0] : Math.max(range.min, xScale.domain()[0]),
+ max: range.max == null ? xScale.domain()[1] : Math.min(range.max, xScale.domain()[1])
+ }
+ }
+
+ const chart: Histogram = {
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Chart lifecyle methods
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ destroy: () => {
+ if (svg) {
+ svg.remove()
+ svg = null
+ }
+ if (tooltip) {
+ tooltip.remove()
+ tooltip = null
+ }
+ if (selectionTooltip) {
+ selectionTooltip.remove()
+ selectionTooltip = null
+ }
+ data = []
+ series = []
+ },
+
+ render: (container: HTMLElement) => {
+ _container = container
+
+ if (_container) {
+ svg = d3.select(_container)
+ .html(null)
+ .append('svg')
+ svg.append('defs')
+ const mainGroup = svg.append('g')
+ .attr('class', 'histogram-main')
+ .attr('transform', `translate(${margins.left},${margins.top})`)
+ mainGroup.append('g')
+ .attr('class', 'histogram-ranges')
+ mainGroup.append('g')
+ .attr('class', 'histogram-range-thresholds')
+ mainGroup.append('g')
+ .attr('class', 'histogram-bars')
+ mainGroup.append('g')
+ .attr('class', 'histogram-left-axis')
+ mainGroup.append('g')
+ .attr('class', 'histogram-bottom-axis')
+ mainGroup.append('g')
+ .attr('class', 'histogram-legend-background')
+ mainGroup.append('g')
+ .attr('class', 'histogram-legend')
+ mainGroup.append('g')
+ .attr('class', 'histogram-hovers')
+ } else {
+ svg = null
+ }
+
+ renderTooltips()
+ chart.resize()
+
+ return chart
+ },
+
+ refresh: () => {
+ if (_container && svg) {
+ chart.resize()
+ prepareData()
+
+ //resizeTo Container()
+
+ // Calculate space required for y axis and label for the largest possible number of bin members. We will need to
+ // re-scale the y-axis for displaying smaller numbers but this will give us enough space for the y-axis at maximum
+ // axis width (i.e. with the longest numbers in the counts.
+ //
+ // Also leave 5% breathing room at the top of the chart.
+ const yMax = (d3.max(series, (s) => s.maxBinSize) || 0) * 1.10
+ const chartHasContent = yMax > 0
+ yScale.domain([0, yMax])
+ .range([height, 0])
+
+ // Add temporary y axis and measure its width
+ const tempYAxis = svg.append('g')
+ .style('visibility', 'hidden')
+ .call(d3.axisLeft(yScale).ticks(10))
+ const yAxisWidthWithLabel = (tempYAxis.node()?.getBoundingClientRect()?.width || 0) + LABEL_SIZE
+ tempYAxis.remove()
+
+ // Calculate final margins using calculated width.
+ effectiveMargins = {
+ ...margins,
+ left: margins.left + yAxisWidthWithLabel
+ }
+ width = _container.clientWidth - (effectiveMargins.left + effectiveMargins.right)
+
+ // Update the main group's margins inside the SVG.
+ svg.select('g.histogram-main')
+ .attr('transform', `translate(${effectiveMargins.left}, ${effectiveMargins.top})`)
+
+ // Set the X scale. Expand its domain from that of the data by the size of the first and last bin. Assume that
+ // all bins are of equal size.
+ if (bins.length > 0) {
+ const firstBinInfo = bins[0]
+ const lastBinInfo = bins[bins.length - 1]
+ xScale.domain([
+ firstBinInfo.x0 - (firstBinInfo.x1 - firstBinInfo.x0),
+ lastBinInfo.x1 * 2 - lastBinInfo.x0
+ ])
+ } else {
+ xScale.domain([0, 0])
+ }
+ xScale.range([0, width])
+
+ // Refresh the axes.
+ svg.select('g.histogram-bottom-axis')
+ .attr('transform', `translate(0,${height})`)
+ // @ts-ignore
+ .call(d3.axisBottom(xScale).ticks(10))
+ svg.select('g.histogram-left-axis')
+ // @ts-ignore
+ .call(d3.axisLeft(yScale).ticks(10))
+
+ // Refresh the chart title.
+ svg.select('g.histogram-main')
+ .selectAll('text.histogram-title')
+ .data(title ? [title] : [], (d) => d as any)
+ .join('text')
+ .attr('class', 'histogram-title')
+ .attr('x', width / 2 )
+ .attr('y', 4)
+ .style('text-anchor', 'middle')
+ .text((d) => d)
+
+ // Refresh the axis labels.
+ svg.select('g.histogram-main')
+ .selectAll('text.histogram-bottom-axis-label')
+ .data(bottomAxisLabel ? [bottomAxisLabel] : [], (d) => d as any)
+ .join('text')
+ .attr('class', 'histogram-axis-label histogram-bottom-axis-label')
+ .attr('font-size', LABEL_SIZE)
+ .attr('x', width / 2)
+ .attr('y', height + 25)
+ .style('text-anchor', 'middle')
+ .text((d) => d)
+ svg.select('g.histogram-main')
+ .selectAll('text.histogram-left-axis-label')
+ .data(leftAxisLabel ? [leftAxisLabel] : [], (d) => d as any)
+ .join('text')
+ .attr('class', 'histogram-axis-label histogram-left-axis-label')
+ .attr('font-size', LABEL_SIZE)
+ .attr('transform', `translate(${-(yAxisWidthWithLabel - LABEL_SIZE / 2)}, ${height / 2}) rotate(-90)`)
+ .style('text-anchor', 'middle')
+ .text((d) => d)
+
+ // Refresh the legend, which is displayed when there is more than one serie.
+ const legendX = 32
+ const legendY = 12
+ const legendItemHeight = 22
+ const legendFontSize = '13px'
+ const legendCircleWidth = 7
+ const legendSpacing = 5
+ const legend = svg.select('g.histogram-legend')
+ const legendItem = legend.selectAll('g.histogram-legend-item')
+ .data(chartHasContent && series.length > 1 ? series : [])
+ .join(
+ (enter) => {
+ const g = enter.append('g')
+ .attr('class', 'histogram-legend-item')
+ g.append('circle')
+ .attr('r', legendCircleWidth)
+ .attr('cx', legendX)
+ //.attr('cy', (d, i) => legendY + i * legendItemHeight)
+ //.style('fill', (d) => d.options.color)
+ g.append('text')
+ .attr('x', legendX + legendCircleWidth + legendSpacing)
+ .attr('y', (_d: HistogramSerie, i) => legendY + i * legendItemHeight + legendSpacing)
+ .style('font-size', legendFontSize)
+ //.text((d, i) => d.options.title || `Series ${i + 1}`)
+ return g
+ },
+ (update) => update,
+ (exit) => exit.remove()
+ )
+ legendItem.select('circle')
+ // @ts-ignore
+ .attr('cy', (_d: HistogramSerie, i) => legendY + i * legendItemHeight)
+ // @ts-ignore
+ .style('fill', (d: HistogramSerie) => d.options.color)
+ legendItem.select('text')
+ // @ts-ignore
+ .text((d: HistogramSerie, i) => d.options.title || `Series ${i + 1}`)
+
+ // The client may have specified a line of text to display below the legend.
+ legend.selectAll('text.histogram-legend-note')
+ .data(chartHasContent && legendNote ? [legendNote] : [])
+ .join('text')
+ .attr('class', 'histogram-legend-note')
+ .attr('font-size', legendFontSize)
+ .attr('x', legendX - legendCircleWidth)
+ .attr('y', legendY + (series.length == 1 ? 0 : series.length) * legendItemHeight + legendSpacing - 1)
+ .text((d) => d)
+
+ // Add a background for the legend, for visibility.
+ const legendBounds = (legend.node() as SVGGraphicsElement | null)?.getBBox()
+ svg.select('g.histogram-legend-background')
+ .selectAll('rect.histogram-legend-background')
+ .data(legendBounds ? [legendBounds] : [])
+ .join('rect')
+ .attr('class', 'histogram-legend-background')
+ .attr('fill', '#ffffff')
+ .attr('fill-opacity', '.6')
+ .attr('y', (d) => d.x - 5 - margins.top)
+ .attr('x', (d) => d.x - 5)
+ .attr('height', (d) => d.height + 10)
+ .attr('width', (d) => d.width + 10)
+
+ svg.selectAll('text.histogram-no-data-message')
+ .data(chartHasContent ? [] : ['No data'])
+ .join('text')
+ .attr('class', 'histogram-no-data-message')
+ .attr('x', width / 2 )
+ .attr('y', height / 2 )
+ .style('text-anchor', 'middle')
+ .text((d) => d)
+
+ const path = d3.line((d) => xScale(d[0]), (d) => yScale(d[1]))
+
+ const seriesLine = svg.select('g.histogram-bars')
+ .selectAll('g.histogram-line')
+ .data(chartHasContent ? series : [], (d) => d as any)
+ // @ts-ignore
+ .join(
+ // @ts-ignore
+ (enter) => {
+ const g = enter.append('g')
+ .attr('class', 'histogram-line')
+ g.append('path')
+ return g
+ },
+ (update) => update,
+ (exit) => exit.remove()
+ )
+ seriesLine.select('path')
+ .attr('class', 'histogram-line')
+ .attr('fill', (d) => d.options.color)
+ .attr('fill-opacity', '.25')
+ .attr('stroke', (d) => d.options.color)
+ .attr('stroke-width', 1.5)
+ .attr('d', (d) => path(d.line))
+
+ // Refresh the hover and highlight boxes.
+ const hovers = svg.select('g.histogram-hovers')
+ .selectAll('g.histogram-hover')
+ .data(bins, (d) => d as any)
+ .join('g')
+ .attr('class', 'histogram-hover')
+ .on('mouseover', mouseover)
+ .on('mousemove', mousemove)
+ .on('mouseleave', mouseleave)
+
+ // Hover target is the full height of the chart.
+ hovers.append('rect')
+ .attr('class', (d) => `histogram-hover-target`)
+ .attr('x', (d) => xScale(d.x0))
+ .attr('width', (d) => xScale(d.x1) - xScale(d.x0))
+ .attr('y', () => yScale(yMax))
+ .attr('height', () => yScale(0) - yScale(yMax))
+ .style('fill', 'transparent') // Necessary for mouse events to fire.
+
+ // However, only the largest bin is highlighted on hover.
+ hovers.append('rect')
+ .attr('class', (d) => `histogram-hover-highlight`)
+ .attr('x', (d) => xScale(d.x0))
+ .attr('width', (d) => xScale(d.x1) - xScale(d.x0))
+ .attr('y', (d) => yScale(d.yMax))
+ .attr('height', (d) => yScale(0) - yScale(d.yMax))
+ .style('fill', 'none')
+ .style('stroke', 'black')
+ .style('stroke-width', 1.5)
+ .style('opacity', d => hoverOpacity(d))
+
+ // Refresh the ranges.
+ svg.select('defs')
+ .selectAll('linearGradient')
+ .data(ranges)
+ .join(
+ (enter) => {
+ const gradient = enter.append('linearGradient')
+ .attr('id', (d, i) => `histogram-range-gradient-${i}`) // TODO Include a UUID for this chart.
+ .attr('gradientTransform', 'rotate(45)')
+ gradient.append('stop')
+ .attr('offset', '0')
+ .attr('stop-color', (d) => d.color || DEFAULT_SERIES_COLOR)
+ .attr('stop-opacity', '0.15')
+ gradient.append('stop')
+ .attr('offset', '100%')
+ .attr('stop-color', (d) => d.color || DEFAULT_SERIES_COLOR)
+ .attr('stop-opacity', '0.05')
+ return gradient
+ }
+ )
+ const rangeG = svg.select('g.histogram-ranges')
+ .selectAll('g.histogram-range')
+ .data(chartHasContent ? ranges : [])
+ .join(
+ (enter) => {
+ const g = enter.append('g')
+ g.append('polygon')
+ g.append('text')
+ return g
+ },
+ (update) => update,
+ (exit) => exit.remove()
+ )
+ rangeG.attr('class', 'histogram-range')
+ .style('fill', (d, i) => `url(#histogram-range-gradient-${i})`)
+ rangeG.select('polygon')
+ .attr('points', (d) => rangePolygon(d, yMax).map(([x, y]) => `${xScale(x)},${yScale(y)}`).join(' '))
+ rangeG.select('text')
+ .attr('class', 'histogram-range-title')
+ .style('fill', (d) => d.color || '#000000')
+ .attr('x', (d) => {
+ const span = visibleRange(d)
+ return xScale((span.min + span.max) / 2)
+ })
+ .attr('y', 15)
+ .style('text-anchor', 'middle')
+ .style('visibility', (d) => d.title ? 'visible' : 'hidden')
+ .text((d) => d.title)
+ const rangeThresholds = ranges.map((range) => [
+ ...(range.min != null && range.min > xScale.domain()[0]) ? [{x: range.min, range}] : [],
+ ...(range.max != null && range.max < xScale.domain()[1]) ? [{x: range.max, range}] : [],
+ ]).flat()
+ svg.select('g.histogram-range-thresholds')
+ .selectAll('path.histogram-range-threshold')
+ .data(chartHasContent ? rangeThresholds : [])
+ .join('path')
+ .attr('class', 'histogram-range-threshold')
+ .attr('stroke', (d) => d.range.color || DEFAULT_RANGE_COLOR)
+ .attr('stroke-dasharray', '4 4')
+ .attr('stroke-width', 1.5)
+ .attr('d', (d) => {
+ const intersectedBinIndex = findBinIndex(d.x)
+ let yMin = (intersectedBinIndex == null) ? yScale.domain()[0] : bins[intersectedBinIndex].yMax
+ if (intersectedBinIndex != null && intersectedBinIndex > 0 && d.x == bins[intersectedBinIndex].x0) {
+ yMin = Math.max(yMin, bins[intersectedBinIndex - 1].x1)
+ }
+ return path([[d.x, yMin], [d.x, yMax]])
+ })
+ }
+
+ updateSelectionAfterRefresh()
+
+ return chart
+ },
+
+ resize: () => {
+ if (_container && svg) {
+ const heightWithMargins = $(_container).height() || 500
+ const widthWithMargins = $(_container).width() || 500
+ height = heightWithMargins - margins.top - margins.bottom
+ width = widthWithMargins - margins.left - margins.right
+ svg.attr('height', heightWithMargins)
+ .attr('width', widthWithMargins)
+ }
+ return chart
+ },
+
+ clearSelection: () => {
+ selectedBin = null
+ selectedDatum = null
+ refreshHighlighting()
+ hideSelectionTooltip()
+ },
+
+ selectBin: (binIndex: number) => {
+ selectedBin = bins[binIndex] || null
+ selectedDatum = null
+ refreshHighlighting()
+ showSelectionTooltip()
+ },
+
+ selectDatum: (d: HistogramDatum) => {
+ selectedDatum = d
+ const value = applyField(d, valueField)
+
+ // Also select the bin in which the datum falls.
+ const selectedBinIndex = findBinIndex(value)
+ selectedBin = selectedBinIndex == null ? null : bins[selectedBinIndex]
+
+ refreshHighlighting()
+ showSelectionTooltip()
+ },
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Accessors
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ data: (value?: HistogramDatum[]) => {
+ if (value === undefined) {
+ return data
+ }
+ data = value
+ return chart
+ },
+
+ seriesOptions: (value?: HistogramSerieOptions[] | null) => {
+ if (value === undefined) {
+ return seriesOptions
+ }
+ seriesOptions = value
+ return chart
+ },
+
+ seriesClassifier: (value?: ((d: HistogramDatum) => number[]) | null) => {
+ if (value === undefined) {
+ return seriesClassifier
+ }
+ seriesClassifier = value
+ return chart
+ },
+
+ ranges: (value?: HistogramRange[]) => {
+ if (value === undefined) {
+ return ranges
+ }
+ ranges = value
+ return chart
+ },
+
+ numBins: (value?: number) => {
+ if (value === undefined) {
+ return numBins
+ }
+ numBins = value
+ return chart
+ },
+
+ valueField: (value?: FieldGetter) => {
+ if (value === undefined) {
+ return valueField
+ }
+ valueField = value
+ return chart
+ },
+
+ tooltipHtml: (value?: ((
+ datum: HistogramDatum | null,
+ bin: HistogramBin | null,
+ seriesContainingDatum: HistogramSerieOptions[],
+ allSeries: HistogramSerieOptions[]
+ ) => string | null) | null) => {
+ if (value === undefined) {
+ return tooltipHtml
+ }
+ tooltipHtml = value
+ return chart
+ },
+
+ margins: (value?: HistogramMargins) => {
+ if (value === undefined) {
+ return margins
+ }
+ margins = value
+ return chart
+ },
+
+ title: (value?: string | null) => {
+ if (value === undefined) {
+ return title
+ }
+ title = value
+ return chart
+ },
+
+ leftAxisLabel: (value?: string | null) => {
+ if (value === undefined) {
+ return leftAxisLabel
+ }
+ leftAxisLabel = value
+ return chart
+ },
+
+ bottomAxisLabel: (value?: string | null) => {
+ if (value === undefined) {
+ return bottomAxisLabel
+ }
+ bottomAxisLabel = value
+ return chart
+ },
+
+ legendNote: (value?: string | null) => {
+ if (value === undefined) {
+ return legendNote
+ }
+ legendNote = value
+ return chart
+ },
+
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // Getters
+ ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ selectedBin: () => selectedBin,
+
+ selectedDatum: () => selectedDatum,
+
+ container: () => _container,
+
+ height: () => height,
+
+ width: () => width
+ }
+
+ return chart
+}
diff --git a/src/lib/item-types.js b/src/lib/item-types.js
index 79e0b01f..c19d339c 100644
--- a/src/lib/item-types.js
+++ b/src/lib/item-types.js
@@ -11,7 +11,7 @@ const itemTypes = {
}
},
'controlled-keywords-variant-search': {
- name: 'controlled-keywords-variant-library-search',
+ name: 'controlled-keywords-variant-library-search',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -21,7 +21,7 @@ const itemTypes = {
}
},
'controlled-keywords-endo-system-search': {
- name: 'controlled-keyword-endogenous-locus-library-method-system',
+ name: 'controlled-keyword-endogenous-locus-library-method-system',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -31,7 +31,7 @@ const itemTypes = {
}
},
'controlled-keywords-endo-mechanism-search': {
- name: 'controlled-keywords-endogenous-locus-library-method-mechanism',
+ name: 'controlled-keywords-endogenous-locus-library-method-mechanism',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -41,7 +41,7 @@ const itemTypes = {
}
},
'controlled-keywords-in-vitro-system-search': {
- name: 'controlled-keywords-in-vitro-construct-library-method-system',
+ name: 'controlled-keywords-in-vitro-construct-library-method-system',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -51,7 +51,7 @@ const itemTypes = {
}
},
'controlled-keywords-in-vitro-mechanism-search': {
- name: 'controlled-keywords-in-vitro-construct-library-method-mechanism',
+ name: 'controlled-keywords-in-vitro-construct-library-method-mechanism',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -61,7 +61,7 @@ const itemTypes = {
}
},
'controlled-keywords-delivery-search': {
- name: 'controlled-keywords-delivery-method',
+ name: 'controlled-keywords-delivery-method',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -71,7 +71,7 @@ const itemTypes = {
}
},
'controlled-keywords-phenotypic-dimensionality-search': {
- name: 'controlled-keywords-phenotypic-assay-dimensionality',
+ name: 'controlled-keywords-phenotypic-assay-dimensionality',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -81,7 +81,7 @@ const itemTypes = {
}
},
'controlled-keywords-phenotypic-method-search': {
- name: 'controlled-keywords-phenotypic-assay-method',
+ name: 'controlled-keywords-phenotypic-assay-method',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -91,7 +91,7 @@ const itemTypes = {
}
},
'controlled-keywords-phenotypic-modle-system-search': {
- name: 'controlled-keywords-phenotypic-assay-model-system',
+ name: 'controlled-keywords-phenotypic-assay-model-system',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -101,7 +101,7 @@ const itemTypes = {
}
},
'controlled-keywords-phenotypic-profiling-strategy-search': {
- name: 'controlled-keywords-phenotypic-assay-profiling-strategy',
+ name: 'controlled-keywords-phenotypic-assay-profiling-strategy',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -111,7 +111,7 @@ const itemTypes = {
}
},
'controlled-keywords-phenotypic-sequencing-type-search': {
- name: 'controlled-keywords-phenotypic-assay-sequencing-read-type',
+ name: 'controlled-keywords-phenotypic-assay-sequencing-read-type',
restCollectionName: 'controlled-keywords',
httpOptions: {
list: {
@@ -144,6 +144,16 @@ const itemTypes = {
name: 'license', // TODO Redundant, change this structure
restCollectionName: 'licenses'
},
+ 'active-license': {
+ name: 'active-license', // TODO Redundant, change this structure
+ restCollectionName: 'active-licenses',
+ httpOptions: {
+ list: {
+ method: 'get',
+ url: `${config.apiBaseUrl}/licenses/active`
+ }
+ }
+ },
'pubmedPublicationIdentifier': {
name: 'pubmedPublicationIdentifier', // TODO Redundant, change this structure
restCollectionName: 'publication-identifiers',
@@ -237,7 +247,7 @@ const itemTypes = {
httpOptions: {
list: {
method: 'post',
- url: `${config.apiBaseUrl}/target-genes/search`
+ url: `${config.apiBaseUrl}/me/target-genes/search`
}
}
},
diff --git a/src/lib/mave-hgvs.ts b/src/lib/mave-hgvs.ts
index daf5d235..90bd8090 100644
--- a/src/lib/mave-hgvs.ts
+++ b/src/lib/mave-hgvs.ts
@@ -1,3 +1,17 @@
+/**
+ * An object holding parsed values from a simple MaveHGVS-pro string representing a variation at one locus in a protein.
+ */
+interface SimpleMaveVariant {
+ /** The MaveDB Accession for a variant. */
+ accession: string
+ /** The nucleotide HGVS string. */
+ hgvs_nt: string
+ /** The protein HGVS string. */
+ hgvs_pro: string
+ /** The splice HGVS string. */
+ hgvs_splice: string
+}
+
/**
* An object holding parsed values from a simple MaveHGVS-pro string representing a variation at one locus in a protein.
*/
@@ -17,13 +31,18 @@ interface SimpleProteinVariation {
target: null | string
}
+type VariantLabel = {
+ mavedb_label: string
+}
+
/**
- * Regular expression for parsing simple MaveHGVS-pro expressions.
+ * Regular expression for parsing simple MaveHGVS-pro and -nt expressions.
*
* MaveHGVS doesn't allow single-character codes in substitutions, but for flexibility we allow * and - here. These are
* properly represented as Ter and del in MaveHGVS.
*/
const proVariantRegex = /^p\.([A-Za-z]{3})([0-9]+)([A-Za-z]{3}|=|\*|-)$/
+const ntVariantRegex = /^c|g|n\.([0-9]+)([ACGTacgt]{1})(>)([ACGTactg]{1})$/
/**
* Parse a MaveHGVS protein variant representing a variation at one locus.
@@ -48,6 +67,29 @@ export function parseSimpleProVariant(variant: string): SimpleProteinVariation |
}
}
+/**
+ * Parse a MaveHGVS nucleotide variant representing a variation at one locus.
+ *
+ * @param variant A MaveHGVS-nt variant string representing a single variation.
+ * @returns An object with properties indicating
+ */
+export function parseSimpleNtVariant(variant: string): SimpleProteinVariation | null {
+ const parts = variant.split(":")
+ const variation = parts.length == 1 ? parts[0] : parts[1]
+ const target = parts.length == 1 ? null : parts[0]
+ const match = variation.match(ntVariantRegex)
+ if (!match) {
+ // console.log(`WARNING: Unrecognized pro variant: ${variant}`)
+ return null
+ }
+ return {
+ position: parseInt(match[1]),
+ original: match[2],
+ substitution: match[4],
+ target: target
+ }
+}
+
/**
* Checks whether a provided variant is null or na
@@ -58,3 +100,25 @@ export function parseSimpleProVariant(variant: string): SimpleProteinVariation |
export function variantNotNullOrNA(variant: string | null | undefined): boolean {
return variant ? variant.toLowerCase() !== "na" : false
}
+
+
+/**
+ * Return the preferred variant label for a given variant. Protein variation is preferred
+ * to nucleotide variation, which is preferred to splice variation.
+ *
+ * hgvs_pro > hgvs_nt > hgvs_splice
+ *
+ * @param variant An object representing a simple MaveDB variant.
+ * @returns the label of the preferred variant, or just the accession if none are valid.
+ */
+export function preferredVariantLabel(variant: SimpleMaveVariant): VariantLabel {
+ if (variantNotNullOrNA(variant.hgvs_pro)) {
+ return {mavedb_label: variant.hgvs_pro}
+ } else if (variantNotNullOrNA(variant.hgvs_nt)) {
+ return {mavedb_label: variant.hgvs_nt}
+ } else if (variantNotNullOrNA(variant.hgvs_splice)) {
+ return {mavedb_label: variant.hgvs_splice}
+ } else {
+ return {mavedb_label: variant.accession}
+ }
+}
diff --git a/src/lib/nucleotides.ts b/src/lib/nucleotides.ts
new file mode 100644
index 00000000..9138aca5
--- /dev/null
+++ b/src/lib/nucleotides.ts
@@ -0,0 +1,7 @@
+/** DNA bases and their codes. */
+export const NUCLEOTIDE_BASES = [
+ {name: 'Adenine', codes: {single: 'A'}},
+ {name: 'Cytosine', codes: {single: 'C'}},
+ {name: 'Guanine', codes: {single: 'G'}},
+ {name: 'Thymine', codes: {single: 'T'}},
+ ]
diff --git a/src/lib/target-genes.ts b/src/lib/target-genes.ts
new file mode 100644
index 00000000..9e9e32ca
--- /dev/null
+++ b/src/lib/target-genes.ts
@@ -0,0 +1,20 @@
+export type TargetGeneCategory = "protein_coding" | "regulatory" | "other_noncoding"
+
+export const TARGET_GENE_CATEGORIES: TargetGeneCategory[] = [
+ 'protein_coding',
+ 'regulatory',
+ 'other_noncoding'
+]
+
+export function textForTargetGeneCategory(cat: TargetGeneCategory): string | undefined {
+ switch (cat) {
+ case "protein_coding":
+ return "Protein Coding"
+ case "regulatory":
+ return "Regulatory"
+ case "other_noncoding":
+ return "Other noncoding"
+ default:
+ return undefined
+ }
+}
diff --git a/src/router/index.js b/src/router/index.js
index a36af4d8..f833875b 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -16,8 +16,11 @@ import ScoreSetView from '@/components/screens/ScoreSetView'
import SearchView from '@/components/screens/SearchView'
import SettingsScreen from '@/components/screens/SettingsScreen'
import UsersView from '@/components/screens/UsersView'
+import VariantScreen from '@/components/screens/VariantScreen'
import store from '@/store'
+import config from '@/config'
+
const routes = [{
path: '/',
name: 'home',
@@ -99,7 +102,14 @@ const routes = [{
props: (route) => ({
itemId: route.params.urn,
})
-}, {
+}, ...config.CLINICAL_FEATURES_ENABLED ? [{
+ path: '/variants/:urn',
+ name: 'variant',
+ component: VariantScreen,
+ props: (route) => ({
+ variantUrn: route.params.urn,
+ })
+}] : [], {
name: 'pubmedPublicationIdentifier',
path: '/publication-identifiers/pubmed/:identifier',
component: PublicationIdentifierView,