|
| 1 | +import styles from "./main.module.sass"; |
| 2 | +import classNames from "classnames"; |
| 3 | +import { Tag } from "@blueprintjs/core"; |
| 4 | +import { Entity, EntityExt, Highlight, EntityType } from "./types"; |
| 5 | +import { CSSProperties } from "react"; |
| 6 | +import { asChromaColor } from "@macrostrat/color-utils"; |
| 7 | +import hyper from "@macrostrat/hyper"; |
| 8 | + |
| 9 | +const h = hyper.styled(styles); |
| 10 | + |
| 11 | +export function buildHighlights( |
| 12 | + entities: EntityExt[], |
| 13 | + parent: EntityExt | null |
| 14 | +): Highlight[] { |
| 15 | + let highlights = []; |
| 16 | + let parents = []; |
| 17 | + if (parent != null) { |
| 18 | + parents = [parent.id, ...(parent.parents ?? [])]; |
| 19 | + } |
| 20 | + |
| 21 | + for (const entity of entities) { |
| 22 | + highlights.push({ |
| 23 | + start: entity.indices[0], |
| 24 | + end: entity.indices[1], |
| 25 | + text: entity.name, |
| 26 | + backgroundColor: entity.type.color ?? "#ddd", |
| 27 | + tag: entity.type.name, |
| 28 | + id: entity.id, |
| 29 | + parents, |
| 30 | + }); |
| 31 | + highlights.push(...buildHighlights(entity.children ?? [], entity)); |
| 32 | + } |
| 33 | + return highlights; |
| 34 | +} |
| 35 | + |
| 36 | +export function enhanceData(extractionData, models, entityTypes) { |
| 37 | + return { |
| 38 | + ...extractionData, |
| 39 | + model: models.get(extractionData.model_id), |
| 40 | + entities: extractionData.entities?.map((d) => |
| 41 | + enhanceEntity(d, entityTypes) |
| 42 | + ), |
| 43 | + }; |
| 44 | +} |
| 45 | + |
| 46 | +export function getTagStyle( |
| 47 | + baseColor: string, |
| 48 | + options: { highlighted?: boolean; inDarkMode?: boolean; active?: boolean } |
| 49 | +): CSSProperties { |
| 50 | + const _baseColor = asChromaColor(baseColor ?? "#ddd"); |
| 51 | + const { highlighted = true, inDarkMode = false, active = false } = options; |
| 52 | + |
| 53 | + let mixAmount = highlighted ? 0.8 : 0.5; |
| 54 | + let backgroundAlpha = highlighted ? 0.8 : 0.2; |
| 55 | + |
| 56 | + if (active) { |
| 57 | + mixAmount = 1; |
| 58 | + backgroundAlpha = 1; |
| 59 | + } |
| 60 | + |
| 61 | + const mixTarget = inDarkMode ? "white" : "black"; |
| 62 | + |
| 63 | + const color = _baseColor.mix(mixTarget, mixAmount).css(); |
| 64 | + const borderColor = highlighted |
| 65 | + ? _baseColor.mix(mixTarget, mixAmount / 2).css() |
| 66 | + : "transparent"; |
| 67 | + |
| 68 | + return { |
| 69 | + color, |
| 70 | + backgroundColor: _baseColor.alpha(backgroundAlpha).css(), |
| 71 | + boxSizing: "border-box", |
| 72 | + borderStyle: "solid", |
| 73 | + borderColor, |
| 74 | + borderWidth: "1px", |
| 75 | + fontWeight: active ? "bold" : "normal", |
| 76 | + }; |
| 77 | +} |
| 78 | + |
| 79 | +function enhanceEntity( |
| 80 | + entity: Entity, |
| 81 | + entityTypes: Map<number, EntityType> |
| 82 | +): EntityExt { |
| 83 | + return { |
| 84 | + ...entity, |
| 85 | + type: addColor(entityTypes.get(entity.type), entity.match != null), |
| 86 | + children: entity.children?.map((d) => enhanceEntity(d, entityTypes)), |
| 87 | + }; |
| 88 | +} |
| 89 | + |
| 90 | +function addColor(entityType: EntityType, match = false) { |
| 91 | + let color = entityType.color ?? "#ddd"; |
| 92 | + |
| 93 | + color = asChromaColor(color).brighten(match ? 1 : 2); |
| 94 | + |
| 95 | + return { ...entityType, color: color.css() }; |
| 96 | +} |
| 97 | + |
| 98 | +export function ExtractionContext({ |
| 99 | + data, |
| 100 | + entityTypes, |
| 101 | +}: { |
| 102 | + data: any; |
| 103 | + entityTypes: Map<number, EntityType>; |
| 104 | +}) { |
| 105 | + const highlights = buildHighlights(data.entities); |
| 106 | + |
| 107 | + return h("div", [ |
| 108 | + h("p", h(HighlightedText, { text: data.paragraph_text, highlights })), |
| 109 | + h(ModelInfo, { data: data.model }), |
| 110 | + h( |
| 111 | + "ul.entities", |
| 112 | + data.entities.map((d) => h(ExtractionInfo, { data: d })) |
| 113 | + ), |
| 114 | + ]); |
| 115 | +} |
| 116 | + |
| 117 | +export function ModelInfo({ data }) { |
| 118 | + return h("p.model-name", ["Model: ", h("code.bp5-code", data.name)]); |
| 119 | +} |
| 120 | + |
| 121 | +export function EntityTag({ |
| 122 | + data, |
| 123 | + highlighted = true, |
| 124 | + active = false, |
| 125 | + onClickType, |
| 126 | +}) { |
| 127 | + const { name, type, match } = data; |
| 128 | + const className = classNames( |
| 129 | + { |
| 130 | + matched: match != null, |
| 131 | + type: data.type.name, |
| 132 | + }, |
| 133 | + "entity" |
| 134 | + ); |
| 135 | + |
| 136 | + const style = getTagStyle(type.color, { highlighted, active }); |
| 137 | + |
| 138 | + return h(Tag, { style, className }, [ |
| 139 | + h("span.entity-name", name), |
| 140 | + " ", |
| 141 | + h( |
| 142 | + "code.entity-type.bp5-code", |
| 143 | + { |
| 144 | + onClick(evt) { |
| 145 | + if (active && onClickType != null) { |
| 146 | + onClickType(type); |
| 147 | + evt.stopPropagation(); |
| 148 | + } |
| 149 | + }, |
| 150 | + }, |
| 151 | + [type.name, h(Match, { data: match })] |
| 152 | + ), |
| 153 | + ]); |
| 154 | +} |
| 155 | + |
| 156 | +function ExtractionInfo({ data }: { data: EntityExt }) { |
| 157 | + const children = data.children ?? []; |
| 158 | + |
| 159 | + return h("li.entity-row", [ |
| 160 | + h(EntityTag, { data }), |
| 161 | + h.if(children.length > 0)([ |
| 162 | + h( |
| 163 | + "ul.children", |
| 164 | + children.map((d) => h(ExtractionInfo, { data: d })) |
| 165 | + ), |
| 166 | + ]), |
| 167 | + ]); |
| 168 | +} |
| 169 | + |
| 170 | +function Match({ data }) { |
| 171 | + if (data == null) return null; |
| 172 | + const href = buildHref(data); |
| 173 | + return h([" ", h("a.match", { href }, `#${matchID(data)}`)]); |
| 174 | +} |
| 175 | + |
| 176 | +function buildHref(match) { |
| 177 | + /** Build a URL for a matched term */ |
| 178 | + if (match == null) return null; |
| 179 | + |
| 180 | + if (match.strat_name_id != null) { |
| 181 | + return `/lex/strat-names/${match.strat_name_id}`; |
| 182 | + } |
| 183 | + |
| 184 | + if (match.lith_id != null) { |
| 185 | + return `/lex/lithologies`; |
| 186 | + } |
| 187 | + |
| 188 | + if (match.lith_att_id != null) { |
| 189 | + return `/lex/lithologies`; |
| 190 | + } |
| 191 | + |
| 192 | + return null; |
| 193 | +} |
| 194 | + |
| 195 | +function matchID(match) { |
| 196 | + if (match == null) return null; |
| 197 | + |
| 198 | + for (const id of ["strat_name_id", "lith_id", "lith_att_id"]) { |
| 199 | + if (match[id]) { |
| 200 | + return match[id]; |
| 201 | + } |
| 202 | + } |
| 203 | + return null; |
| 204 | +} |
| 205 | + |
| 206 | +function HighlightedText(props: { text: string; highlights: Highlight[] }) { |
| 207 | + const { text, highlights = [] } = props; |
| 208 | + const parts = []; |
| 209 | + let start = 0; |
| 210 | + |
| 211 | + const sortedHighlights = highlights.sort((a, b) => a.start - b.start); |
| 212 | + const deconflictedHighlights = sortedHighlights.map((highlight, i) => { |
| 213 | + if (i === 0) return highlight; |
| 214 | + const prev = sortedHighlights[i - 1]; |
| 215 | + if (highlight.start < prev.end) { |
| 216 | + highlight.start = prev.end; |
| 217 | + } |
| 218 | + return highlight; |
| 219 | + }); |
| 220 | + |
| 221 | + for (const highlight of deconflictedHighlights) { |
| 222 | + const { start: s, end, ...rest } = highlight; |
| 223 | + parts.push(text.slice(start, s)); |
| 224 | + parts.push(h("span.highlight", { style: rest }, text.slice(s, end))); |
| 225 | + start = end; |
| 226 | + } |
| 227 | + parts.push(text.slice(start)); |
| 228 | + return h("span", parts); |
| 229 | +} |
0 commit comments