UNPKG

auspice

Version:

Web app for visualizing pathogen evolution

345 lines (316 loc) 12.3 kB
let Papa: typeof import("papaparse"); /* lazily imported once a file is dropped on */ let XLSX: typeof import("xlsx/xlsx.mini"); /* lazily imported once a file is dropped on */ import { rgb } from "d3-color"; import { NewMetadata, AttrDetails } from "../updateMetadata/updateMetadata.types" /** * ------------------------------------------------------ * General notes about our handling of metadata TSVs/CSVs * ------------------------------------------------------ * * NOTIFICATIONS are dispatched in a somewhat piecemeal fashion (and some * simply use the console). One day we will have a sidebar-style logging * interface which should improve this. * * BOOLEAN SCALES are not well handled in auspice. When we improve this the * values of the existence scale can be booleans themselves. * * DATE is a skipped field, but this should be improved * * SCALE TYPES are always categorical (except for the existence coloring) * */ interface CsvParseError { type: string; code: string; message: string; row?: number; } interface CsvMetadata { delimiter: string; linebreak: string; aborted: boolean; truncated: boolean; fields?: string[]; } interface ParseResults { data: Record<string, string>[]; errors: CsvParseError[]; meta: CsvMetadata; } interface LatLongKeys { latitude: string; longitude: string; } interface AttrColoring { attrName: string; fieldName?: string; scaleType: AttrDetails['scaleType']; strains: AttrDetails['strains']; colors?: AttrDetails['colors']; colorScaleFieldName: string | undefined; } interface Header { fields: string[]; strainKey: string; latLongKeys: LatLongKeys | undefined; ignoredFields: Set<string>; } /** * Reads the dropped file and coverts it to the canonical `NewMetadata` structure * for merging with redux state. Errors result in a rejected promise. The resolved * data structure has not been cross-referenced with redux state, it's simply * a representation of the file contents. */ export async function handleCsvLikeDroppedFile(file: File, nodeNames: Set<string>): Promise<NewMetadata> { const {fileName, csvString} = await readDroppedFile(file); const { errors, data, meta } = await parseCsv(csvString); if (errors.length) throw new Error(errors.map((e) => e.message).join(", ")); let { colorings, header } = processHeader(meta.fields); processRows(colorings, header, data, nodeNames); if (colorings.length === 0) { throw new Error("No valid columns") } // Drop coloring for which no valid strains were found colorings = colorings.filter((coloring) => Object.keys(coloring.strains).length) if (colorings.length === 0) { throw new Error("No matching nodes found in tree") } addExistenceColoring(colorings, fileName); const geographic = header.latLongKeys ? processLatLongs(data, colorings, header, `${fileName}_geo`) : []; return { attributes: Object.fromEntries(colorings.map((c) => [ c.attrName, { key: c.attrName, name: c.attrName, scaleType: c.scaleType, strains: c.strains, colors: c.colors, } ])), geographic, } } /** * A promise-ified version of Papa.parse() * A note on encoding here: It will be common that people drop CSVs from microsoft excel * in here and, you guessed it, this causes all sorts of problems. * https://github.com/mholt/PapaParse/issues/169 suggests adding encoding: "ISO-8859-1" * to the config, which may work */ async function parseCsv(csvString: string): Promise<ParseResults> { if (!Papa) Papa = (await import("papaparse")).default; return new Promise((resolve, reject) => { Papa.parse(csvString, { header: true, complete: (results: ParseResults) => { resolve(results); }, error: (error: Error) => { reject(error); }, encoding: "UTF-8", comments: "#", delimiter: ",", skipEmptyLines: true, dynamicTyping: false }); }); } async function readDroppedFile(file: File): Promise<{fileName: string; csvString: string}> { const fileName = file.name; const reader = new FileReader(); return new Promise((resolve, reject) => { reader.onload = async (_event: ProgressEvent<FileReader>): Promise<void> => { try { /* the XLSX library will handle CSV, TSV, Excel etc, converting to a CSV string */ /* If dropped file is Excel workbook, only reads in the data from the first sheet */ if (!XLSX) XLSX = (await import("xlsx/xlsx.mini")).default; const workbook = XLSX.read(reader.result, { type: 'binary', raw: true }); const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; const csvString = XLSX.utils.sheet_to_csv(firstSheet); resolve({ fileName, csvString }); } catch (err) { reject(err) } } reader.onerror = reject; reader.readAsBinaryString(file); }) } /** * Parses the header row of the CSV file to intitialise `colorings` and return info about the header */ function processHeader(fields: string[]): { colorings: AttrColoring[]; header: Header; } { const strainKey = fields[0]; /* There are a number of "special case" columns we currently ignore */ const fieldsToIgnore = new Set(["name", "div", "vaccine", "labels", "hidden", "mutations", "url", "authors", "accession", "traits", "children"]); fieldsToIgnore.add("num_date").add("year").add("month").add("date"); const latLongFields = new Set(["__latitude", "__longitude", "latitude", "longitude"]); const ignoredFields = new Set<string>(); const colorings: AttrColoring[] = fields.slice(1) .map((fieldName): AttrColoring | null => { if (fieldsToIgnore.has(fieldName)) { ignoredFields.add(fieldName); return null; } if (latLongFields.has(fieldName) || fieldName==='') { return null; } let attrName = fieldName; const scaleType = "categorical"; /* interpret column names using microreact-style syntax */ if (fieldName.includes("__")) { const [prefix, suffix] = fieldName.split("__"); if (["shape", "colour", "color"].includes(suffix)) { // don't track in `ignoredFields` as we don't want a user-facing warning return null; } if (suffix === "autocolour") { attrName = prefix; /* MicroReact uses this to colour things, but we do this by default */ } } return { attrName, colors: [], fieldName, colorScaleFieldName: undefined, scaleType, strains: {} }; }) .filter((x): x is AttrColoring => !!x) .map((data) => { if (fields.includes(`${data.attrName}__colour`)) { data.colorScaleFieldName = `${data.attrName}__colour`; } else if (fields.includes(`${data.attrName}__color`)) { data.colorScaleFieldName = `${data.attrName}__color`; } return data; }); /* check for the presence of lat/long fields */ const latLongKeys: LatLongKeys | undefined = (fields.includes("latitude") && fields.includes("longitude")) ? {latitude: "latitude", longitude: "longitude"} : (fields.includes("__latitude") && fields.includes("__longitude")) ? {latitude: "__latitude", longitude: "__longitude"} : undefined; const header = { fields, strainKey, latLongKeys, ignoredFields } return {colorings, header}; } /** * Adds a (boolean) coloring to show presence of strains in the file */ function addExistenceColoring(colorings: AttrColoring[], fileName: string): void { colorings.push({ attrName: fileName, scaleType: 'boolean', strains: Object.fromEntries( colorings .flatMap((c) => Array.from(Object.keys(c.strains))) .map((s) => [s, { value: `Strains in ${fileName}` }]) ), colors: [], colorScaleFieldName: undefined, }) } function processRows(colorings: AttrColoring[], header: Header, data: ParseResults['data'], nodeNames: Set<string>): void { for (const attrInfo of colorings) { if (!attrInfo.fieldName) { console.error("[internal error] fieldName not set") continue; } /* Track per-row colors defined in the CSV. Since all CSV-based color schemes are non-continuous this data type will be ok. */ const colorsObserved: Record<string, string[]> = {}; const fieldName = attrInfo.fieldName; if (header.ignoredFields.has(fieldName) || fieldName === header.strainKey) continue; for (const row of data) { const strain = row[header.strainKey]; if (!nodeNames.has(strain)) continue; const value = row[fieldName]; if (!value) continue; // skip empty strings (Note: values of `0`, `false` etc are all strings so not skipped) attrInfo.strains[strain] = { value }; // Colors are defined per strain, so store them in a list so we can average as needed if (attrInfo.colorScaleFieldName) { const hex = row[attrInfo.colorScaleFieldName]; if (hex) { if (!colorsObserved[value]) colorsObserved[value] = []; colorsObserved[value].push(hex); } } } // Turn colors into [value, hex] pairs, averaging multiple reported colors as needed const colors: [string, string][] = Object.entries(colorsObserved) .map(([value, hexes]) => [value, _averageColor(hexes)] as const) .filter((entry): entry is [string, string] => entry[1] !== null); if (colors.length) attrInfo.colors = colors; } } /** * Returns the average of a list of hex color values. */ function _averageColor(hexes: string[]): string | null { if (hexes.length === 0) return null const validatedHexes = hexes.filter((h) => h.match(/^#[A-Fa-f0-9]{3}([A-Fa-f0-9]{3})?$/)); if (validatedHexes.length !== hexes.length) { const dropped = Array.from((new Set(hexes)).difference(new Set(validatedHexes))); console.warn(`Validation of color hexes dropped these invalid values: ${dropped.join(', ')}`); } if (validatedHexes.length === 0) return null; if (validatedHexes.length === 1) return hexes[0] let r=0, g=0, b=0; // same algorithm as `getAverageColorFromNodes` validatedHexes.forEach((c) => { const tmpRGB = rgb(c); r += tmpRGB.r; g += tmpRGB.g; b += tmpRGB.b; }); const total = validatedHexes.length; return rgb(r / total, g / total, b / total).toString(); } /** * Metadata defines lat/longs per-sample which is orthogonal to Nextstrain's approach * which associates lat/longs to specific metadata values (e.g. to a specific country). * We get around this by creating a new placeholder attribute which represents the unique * lat/longs provided here. * P.S. Latitude: [-90, 90], Longitude: [-180, 180] */ function processLatLongs( data: ParseResults['data'], colorings: AttrColoring[], header: Header, attrName: string ): NewMetadata['geographic'] { const [latKey, longKey] = [header.latLongKeys.latitude, header.latLongKeys.longitude]; const coordsStrains = new Map(); let demeCounter = 0; /* Collect groups of strains with identical lat/longs */ for (const row of data) { const strain = row[header.strainKey]; const [latitude, longitude] = [Number(row[latKey]), Number(row[longKey])]; if (isNaN(latitude) || isNaN(longitude) || latitude > 90 || latitude < -90 || longitude > 180 || longitude < -180) { continue; } const strKey = String(row[latKey])+String(row[longKey]); if (!coordsStrains.has(strKey)) { const deme = `deme_${demeCounter++}`; // visible to user as the attr value! coordsStrains.set(strKey, {deme, latitude, longitude, strains: new Set<string>()}); } coordsStrains.get(strKey).strains.add(strain); } /* invert map to link each strain to a dummy value with lat/longs */ // TODO XXX what about the shape here? const newGeoResolution = {key: attrName, demes: {}}; const attrColoring: AttrColoring = { attrName, scaleType: 'categorical', strains: {}, colors: [], colorScaleFieldName: undefined, } for (const { deme, latitude, longitude, strains } of coordsStrains.values()) { newGeoResolution.demes[deme] = { latitude, longitude }; for (const strain of strains) { attrColoring.strains[strain] = { value: deme } } } colorings.push(attrColoring); return [newGeoResolution]; }