All files / src/ui export.ts

95.65% Statements 176/184
89.47% Branches 51/57
95.65% Functions 22/23
95.65% Lines 176/184

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415                                          2x 2x 2x     2x     2x                                       2x 2x 2x 2x       2x 11x 11x     2x 151x 151x                           2x 50x 50x 50x 50x 50x 50x 50x 50x                   2x 39x 39x 39x 39x   39x     2x 9x 9x 9x 9x   9x                                             2x                                                     47x 47x 47x 17x 17x 17x 17x 17x 17x 14x 14x 14x 13x 13x 13x 13x 13x 13x 14x 17x   3x       3x 17x 31x 31x 31x 30x 30x 30x 31x 31x                   11x 11x 11x 10x 10x 11x             10x 10x             2x                   17x 17x 17x 17x 17x 17x 17x 17x     2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x     43x 43x 43x 43x 43x 43x 43x 43x     30x 30x 30x 30x 30x 30x 30x 30x 30x 30x     30x 30x                             2x 2x       2x 2x 2x           2x 1x                     22x 22x 22x     22x 22x 6x 6x 16x 15x                       2x 3x 1x 1x 1x 2x 2x 2x 1x 2x 2x     3x 3x 3x 3x 3x                 1319x 1319x 1319x 1319x 1319x 1319x 1181x 1181x 1181x 1181x                   2x 2x                 2x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x    
/**
 * Saving the picture: a raster at a chosen resolution as PNG, or a drawing as
 * PDF.
 *
 * The figure is re-rendered rather than screen-grabbed. Everything the canvas
 * draws — the matrix, the separators, the labels, the ticks — is redrawn at the
 * requested device-pixel ratio, so a 300 dpi export is a 300 dpi *drawing*, not
 * an upscaled screenshot. The one thing that does not change is the layout: the
 * view is exported exactly as it is framed on screen, including the pan and the
 * zoom, because that framing is the thing the user is asking to keep.
 *
 * The PDF is the same drawing written down as drawing, by `ui/vector.ts`: a
 * page that takes the canvas calls every figure here is drawn with and emits
 * paths and text, with a contact map's cells the one image on it. It is
 * written by hand rather than by a PDF library — a dependency for it would be
 * several hundred kilobytes to do the same arithmetic, in an app whose entire
 * point is that it has no server and little else. This module keeps the parts
 * both files share: the DPI arithmetic, the names, the save dialog, the
 * clipboard and the compression.
 */
 
import { logAction } from '../state/actions'
import { fmt } from './dom'
import { messageToast } from './toast'
 
/** CSS pixels are defined as 1/96 inch, which is what makes DPI meaningful. */
export const CSS_DPI = 96
 
/** Offered resolutions. 96 is "what is on screen"; 300 is print. */
export const EXPORT_DPI_CHOICES = [96, 150, 300, 600] as const
 
/**
 * The two files a **map** writes, in the order they are offered.
 *
 * PDF leads because it is the default: a figure ends up in a manuscript, and a
 * page a journal can restyle is what it asks for. PNG is the same picture for
 * a slide or an email.
 *
 * `PDF (vector)`, as the 1D browser's own list says, because that is what the
 * page is: the matrix's cells are the one image on it — a contact map *is* a
 * raster, and twelve thousand bins written as a million rectangles would be a
 * larger, slower file of the same picture — and every axis, tick, label, lane,
 * key and caption round them is paths and text (`MapFigure.vectorFigure`).
 * It was briefly `PDF (image)`, on the argument that the cells are an image;
 * but the word is there to tell a reader opening the file in Illustrator
 * whether there is anything to restyle, and there is — everything but the
 * cells. The resolution beside it is the PNG's alone, and goes while PDF is
 * chosen, as the browser's does.
 */
export const EXPORT_FORMATS = [
  { value: 'pdf', label: 'PDF (vector)' },
  { value: 'png', label: 'PNG' },
] as const
export type ExportFormat = (typeof EXPORT_FORMATS)[number]['value']
 
/** Device pixels per CSS pixel for a target DPI. */
export function scaleForDpi(dpi: number): number {
  return dpi / CSS_DPI
}
 
/** PDF user-space units are points, 1/72 inch. */
export function pointsForCssPixels(cssPixels: number): number {
  return (cssPixels * 72) / CSS_DPI
}
 
/**
 * A filename stem from whatever the view calls itself, with everything a
 * filesystem might object to removed. Empty in, `contact-map` out.
 *
 * A locus comes in as the reader reads it — `IV:1,000,001-1,400,000`, the
 * display form (§26.1) — and goes out as `IV_1000001-1400000`: the grouping
 * commas dropped rather than turned into underscores, which would write a
 * figure of one window as `IV_1_000_001-1_400_000`, a name that reads as four
 * numbers. The bases stay the ones the caption under the picture names; the
 * map's figure was named after the hash's 0-based grammar instead, one base
 * off the window printed inside it.
 */
export function exportBaseName(title: string): string {
  const cleaned = title
    .replace(/(\d),(?=\d{3}(?!\d))/g, '$1')
    .replace(/[×✓]/g, 'x')
    .replace(/[^\w.-]+/g, '_')
    .replace(/^_+|_+$/g, '')
    .slice(0, 80)
  return cleaned === '' ? 'contact-map' : cleaned
}
 
/**
 * `20260803-145233` — the stamp a saved file carries in its name.
 *
 * Local time, not UTC: the person reading the filename is the person who
 * pressed the button, and a figure saved at half past two in the afternoon
 * should say so. Sortable, so a directory of exports is in the order they were
 * made whatever they are called.
 */
export function fileStamp(now: Date): string {
  const pad = (n: number): string => String(n).padStart(2, '0')
  return (
    `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
    `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
  )
}
 
/** `2026-08-03 14:52` — the same instant, for a human reading the figure. */
export function captionStamp(now: Date): string {
  const pad = (n: number): string => String(n).padStart(2, '0')
  return (
    `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
    `${pad(now.getHours())}:${pad(now.getMinutes())}`
  )
}
 
/**
 * Where one file this app writes is going.
 *
 * Chosen before anything is made for it — see {@link chooseSaveTarget} — and
 * written once the bytes exist. `name` is what it will be called, which is the
 * reader's own where the save dialog let them type one, so the line a card
 * writes under its button after a save names the file that is actually there.
 */
export interface SaveTarget {
  readonly name: string
  write(blob: Blob): Promise<void>
}
 
/** The part of the File System Access API this module calls, where a browser has it. */
type SavePicker = (options: {
  suggestedName: string
  startIn?: 'downloads'
  types?: Array<{ description: string; accept: Record<string, string[]> }>
}) => Promise<{ name: string; createWritable(): Promise<{ write(data: Blob): Promise<void>; close(): Promise<void> }> }>
 
/** Every file this window has written, by the name it offered, oldest first. */
const saved: string[] = []
 
/**
 * Ask the reader where a file goes, with its name ready to change — the
 * platform's own save dialog, opened in their Downloads folder.
 *
 * **Asked before the file is made, not after.** A browser opens that dialog
 * only while the click that asked for it is still recent (a few seconds, in
 * Chromium), and an `.mcool`, a bigWig of a whole genome or a deck at 300 dpi
 * take longer than that to produce: asked afterwards, the dialog was refused
 * and the file went wherever the browser puts downloads — the very behaviour
 * this replaces. So every export asks first, from inside the click, and does
 * its work only once there is somewhere to put it; a reader who cancels has
 * not waited for a render nobody wanted.
 *
 * **Every file this app writes leaves through here**, and that is why the
 * console's record of one is written here too rather than at each of the
 * twenty-odd places that ask for one (§23): one choke point, one line, no road
 * around it. What is knowable here is the name and the size, which is exactly
 * what the console's line says; the provenance of what is inside the file is
 * the caller's, and already in the file's own header (§18.4).
 *
 * `null` is that cancel. Where there is no such dialog — Firefox, Safari, a
 * page not served over HTTPS — or the browser refused to open one, the target
 * is the download this app always made, under the name it suggested: the file
 * still arrives, in the one place that browser puts files.
 */
export async function chooseSaveTarget(filename: string, type = ''): Promise<SaveTarget | null> {
  const picker = typeof window === 'undefined' ? undefined : (window as { showSaveFilePicker?: SavePicker }).showSaveFilePicker
  if (typeof picker === 'function') {
    try {
      const handle = await picker.call(window, {
        suggestedName: filename,
        startIn: 'downloads',
        ...acceptFor(filename, type),
      })
      return {
        name: handle.name,
        write: async (blob) => {
          const out = await handle.createWritable()
          await out.write(blob)
          await out.close()
          logWrite(handle.name, blob)
          saved.push(filename)
        },
      }
    } catch (err) {
      // The reader closed the dialog: nothing is written, and nothing is said.
      if (err instanceof DOMException && err.name === 'AbortError') return null
      // Anything else — the click too long ago, a sandboxed frame, a type the
      // dialog would not take — is the dialog failing, not the reader
      // declining, and the file is still owed to them.
    }
  }
  return {
    name: filename,
    write: async (blob) => {
      downloadTo(blob, filename)
      saved.push(filename)
    },
  }
}
 
/**
 * Save a blob the app already has: {@link chooseSaveTarget}, then its write.
 *
 * For the files that exist the moment the button is pressed — a BED of marks,
 * the console's log, a table — where there is no work to put after the dialog.
 * Resolves to the name it was written under, or `null` where the reader
 * cancelled.
 */
export async function downloadBlob(blob: Blob, filename: string): Promise<string | null> {
  const target = await chooseSaveTarget(filename, blob.type)
  if (target === null) return null
  try {
    await target.write(blob)
  } catch (err) {
    // A folder that went read-only, a disk that filled: said where the reader
    // is looking, since a save that silently wrote nothing is a file they will
    // go looking for later.
    messageToast(`Could not write ${target.name}: ${err instanceof Error ? err.message : String(err)}`)
    return null
  }
  return target.name
}
 
/**
 * The names of the files this window has saved, as {@link chooseSaveTarget} offered
 * them — what the tutorial's *save it* steps ask (§30), since a save is a
 * state of the window the reader reached rather than a click it can watch.
 */
export function savedFiles(): readonly string[] {
  return saved
}
 
/**
 * The dialog's file-type filter: one entry, the kind this file is, so the
 * dialog's own list says *PNG image* rather than *All files*. Left out where the
 * name has no extension the dialog would accept, since a filter it refuses is a
 * dialog that does not open.
 */
function acceptFor(filename: string, type: string): { types?: Array<{ description: string; accept: Record<string, string[]> }> } {
  const extension = /\.[A-Za-z0-9]{1,15}$/.exec(filename)?.[0]
  if (extension === undefined) return {}
  const mime = /^[a-z]+\/[A-Za-z0-9.+-]+$/.test(type) ? type : 'application/octet-stream'
  const kind = extension.slice(1).toLowerCase()
  const description = KIND_NAMES[kind] ?? `${extension.slice(1)} file`
  return { types: [{ description, accept: { [mime]: [extension] } }] }
}
 
/** What the save dialog's type list calls the files this app writes most. */
const KIND_NAMES: Record<string, string> = {
  png: 'PNG image',
  pdf: 'PDF document',
  tsv: 'Tab-separated table',
  json: 'JSON',
  bw: 'bigWig track',
  bedgraph: 'bedGraph track',
  bed: 'BED intervals',
  bedpe: 'BEDPE pairs',
  bed3d: 'bed3d structure',
  pdb: 'PDB structure',
  mcool: 'Multi-resolution cooler',
}
 
/** The console's line for a written file (§23): its name and its size. */
function logWrite(filename: string, blob: Blob): void {
  logAction({
    kind: 'write',
    line: `wrote ${filename}`,
    detail: [`# file: ${filename}`, `# size: ${fmt.bytes(blob.size)}`],
    where: filename,
  })
}
 
/** Hand a blob to the browser as a download, then release the object URL. */
function downloadTo(blob: Blob, filename: string): void {
  logWrite(filename, blob)
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = filename
  link.rel = 'noopener'
  document.body.appendChild(link)
  link.click()
  link.remove()
  // Late enough that the download has taken its reference; immediate revocation
  // cancels the save in Safari.
  setTimeout(() => URL.revokeObjectURL(url), 10_000)
}
 
/**
 * Put a PNG on the system clipboard.
 *
 * Takes the *promise*, not the blob, and that is not a convenience. Safari
 * requires the `ClipboardItem` to be constructed while the user gesture that
 * asked for it is still on the stack, and re-drawing a figure at export
 * resolution takes long enough to lose it — so the item is built first, holding
 * a promise the browser waits on itself. Chrome and Firefox accept the same
 * shape, so there is one path rather than a fork on the user agent.
 *
 * A rejected render surfaces as a rejection here, which is what the caller's
 * status line is for.
 */
export async function copyPngToClipboard(png: Promise<Blob>): Promise<void> {
  const scope = globalThis as {
    ClipboardItem?: typeof ClipboardItem
    navigator?: { clipboard?: { write?: (items: ClipboardItem[]) => Promise<void> } }
  }
  const Item = scope.ClipboardItem
  const write = scope.navigator?.clipboard?.write
  if (Item === undefined || write === undefined) {
    // Awaited first, so a figure that could not be drawn says so rather than
    // being reported as a clipboard the browser does not have.
    await png
    throw new Error('This browser will not let a page put an image on the clipboard.')
  }
  await write.call(scope.navigator!.clipboard, [new Item({ 'image/png': png })])
}
 
/**
 * Put a line of text on the system clipboard.
 *
 * Absent in an insecure context — which is where a colleague's `file://` copy of
 * this app runs — and absent in some embeddings, so the API is read defensively
 * and the failure is an ordinary rejection rather than a `TypeError` from
 * calling `undefined`. The same reading {@link copyPngToClipboard} takes of the
 * neighbouring method.
 */
export async function copyTextToClipboard(text: string): Promise<void> {
  const write = (
    globalThis as {
      navigator?: { clipboard?: { writeText?(text: string): Promise<void> } }
    }
  ).navigator?.clipboard?.writeText
  if (write === undefined) {
    throw new Error('This browser will not let a page write to the clipboard.')
  }
  await write.call(navigator.clipboard, text)
}
 
/**
 * One picture onto the clipboard from a right-click menu — *copy this* (§7.2)
 * — with a line in the corner saying what happened.
 *
 * The corner and not a surface's own line, for `ui/toast.ts`'s reason: the menu
 * closes on the press and leaves nowhere nearer to answer in. The size is in
 * the line because it is the one fact about the picture that a paste does not
 * show until it lands. Synchronous up to the clipboard's own call, which a
 * browser only takes inside the press that asked for it.
 */
export function copyPicture(what: string, canvas: HTMLCanvasElement | null): void {
  if (canvas === null) {
    messageToast(`There is no ${what} drawn here to copy.`)
    return
  }
  copyPngToClipboard(canvasToPng(canvas)).then(
    () => messageToast(`Copied the ${what} · ${canvas.width} × ${canvas.height} px`),
    (err: unknown) =>
      messageToast(`The ${what} could not be copied: ${err instanceof Error ? err.message : String(err)}`),
  )
}
 
/** The canvas as a PNG blob. */
export async function canvasToPng(canvas: HTMLCanvasElement): Promise<Blob> {
  const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
  if (blob === null) throw new Error('The browser refused to encode the canvas as a PNG.')
  return blob
}
 
/**
 * zlib-compress, which is what PDF's `/FlateDecode` means.
 *
 * `CompressionStream` is in every browser this app targets. Where it is
 * missing the bytes go in uncompressed — a legal PDF that is simply large,
 * which beats refusing to export at all.
 */
export async function deflateBytes(
  bytes: Uint8Array,
): Promise<{ data: Uint8Array; filter: string | null }> {
  const Compression = (globalThis as { CompressionStream?: typeof CompressionStream })
    .CompressionStream
  if (Compression === undefined) return { data: bytes, filter: null }
  const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new Compression('deflate'))
  const packed = new Uint8Array(await new Response(stream).arrayBuffer())
  return { data: packed, filter: '/FlateDecode' }
}
 
/**
 * The largest canvas a browser will actually give you.
 *
 * Chrome caps the *area* at 2^28 px; Firefox caps each *side* at 32,767; iOS
 * Safari caps the area at 2^24 on memory-constrained devices. Past any of them
 * `getContext` hands back a blank canvas rather than throwing, so an export goes
 * silently white — which is why this is a pre-flight and not a `catch`.
 */
export const CANVAS_MAX_AREA = 268_435_456
export const CANVAS_MAX_SIDE = 32_767
 
/**
 * The largest offered DPI whose canvas fits, or `null` when even the smallest
 * does.
 *
 * Offered as advice rather than as a clamp: the user picked a DPI and is owed a
 * reason and a number, not a quietly different file.
 */
export function largestDpiThatFits(
  cssWidth: number,
  cssHeight: number,
  choices: readonly number[] = EXPORT_DPI_CHOICES,
): number | null {
  const fits = (dpi: number): boolean => {
    const scale = scaleForDpi(dpi)
    const w = Math.max(1, Math.round(cssWidth * scale))
    const h = Math.max(1, Math.round(cssHeight * scale))
    return w * h <= CANVAS_MAX_AREA && w <= CANVAS_MAX_SIDE && h <= CANVAS_MAX_SIDE
  }
  const ok = [...choices].sort((a, b) => b - a).find(fits)
  return ok ?? null
}