Skip to content
chromosomix

chromosomix 0.9.0

What changed in chromosomix 0.9.0, released on 7 August 2026.

  • Four edges, and a 4C profile that is a lane on any of them. A track can be drawn above the map and down its left side as well as below it and beside it, and the four stacks are peers: the same drop rectangles, the same handle to reorder or move a lane, the same border to drag it taller, the same hairline, tooltip, group bars, loop anchors and exported figure. The row’s location control now offers all four, and a lane’s right-click menu offers them too — as words, beside a new item that says which way the signal grows.

    Which way it grows is the one thing that is not a coordinate, and the two axes answer it differently. A horizontal lane grows up the page wherever it is: every browser ever built draws coverage rising from a floor under a ruler, and a profile above a contact map hanging from its ceiling is one a reader has to mirror in their head before they can compare it with the one below. A vertical lane has no such convention, so the corner decides and it grows towards the matrix. Exactly one edge therefore turns a lane on arrival, which is what it was when there were only two — bottom and right behave exactly as before, and moving a lane between two edges turns it again precisely when that answer changes, so a reader who deliberately hung a track from its ceiling keeps that decision through the move.

    What made this affordable is that render/lane.ts is still the only module that knows which of along and across is x. Across now always runs outward, away from the map, which above the map and to its left is the canvas direction reversed — so “the far border”, “the end the handle hangs off” and “where the baseline is” stay one answer each rather than becoming four. The two new stacks cost a coordinate conversion, not a second renderer.

    The virtual 4C profile is now one of these lanes, and that is the point of the change rather than a consequence of it. It was a strip of its own with a canvas of its own, which meant it had none of what a lane has: no name you can type, no limits you can type, no autoscale, no ceiling drag, no floor to drag, no colour, no handle, no readout, no place in the stack. It is now a synthetic entry in the same store every track lives in, so it has all of them, from the one implementation — and can be dragged to any edge like anything else.

    Moved to the left or right of the map it answers a different question, not a rotated one: a strip above or below the map sums a band of rows and answers per column, and a strip beside it sums a band of columns and answers per row. Same anchor, same block, the transpose. Off the diagonal that may have an answer on one axis and none on the other, and the lane says which.

    It also marks where its anchor is on its own axis, as a short bar — the same glyph a group of tracks is marked with — in a gutter it keeps clear off its floor, which is the one end of a lane the signal never reaches, so the mark sits under the line rather than in it. The band being summed is on the other axis and so is nowhere in the picture; this is the fact a reader keeps asking for instead, because on a map read about its diagonal it is where the self-contact peak ought to be. The room comes out of the lane’s own height rather than being added to it, and it is kept whether or not an anchor is set, so the line does not jump under the hand that has just drawn one.

    It carries no file, so it is on no card, is never written to session storage, and is not drawn in the 1D browser. It is also the only lane that draws where there is no coordinate system at all — a contig-level or whole-genome block — because its numbers come from the block on screen rather than from a fetch; there it says, in the lane, that an anchor cannot be named at this level, which is a better answer than the “not in this view” it used to give and than the instruction for a gesture that does nothing there.

    Switching it off hides the lane rather than removing it, so the colour, height, axis and edge a reader chose survive being turned off and on — and on a file that carries no annotations at all the lane belongs to the file rather than to the view, so it survives opening a chromosome as well.

    The empty lane’s instruction is a control and stays on screen: an exported figure gets the outline, so an empty strip reads as a lane with nothing in it, and not a sentence addressed to somebody with a map in front of them.

  • Private buckets. Open a file → private buckets… takes a URL prefix, a region and an access key, and everything under that prefix is then read with a Signature Version 4 signature on every request — the map, its tracks, a published session in the same folder. Everything else, the public demos included, stays unsigned. The dialog also browses a prefix: one ListObjectsV2 per level, folders and files as rows, click to open. That is the half a URL box cannot do, since nobody remembers the key of a .mcool inside collections/hum53/2026-03-run2/.

    The scope is a prefix, not a bucket, so a bucket whose demo/ is public and whose collections/ is not works as one bucket. Signing the public half would add a CORS preflight to every read for nothing, and would fail outright against a key scoped the way docs/hosting.md says to scope it. Matching is longest-prefix, so a per-project key can sit inside a general lab key.

    The signer computes SHA-256 itself (src/s3/sha256.ts) rather than using crypto.subtle, and that is a requirement rather than a preference: the reader’s hot path is a synchronous XMLHttpRequest, because Emscripten’s stream read op is synchronous, so there is no await between HDF5 asking for bytes and the request going out — and every crypto.subtle method returns a promise. Presigned URLs were the alternative and are worse here: scoped to one method, so the HEAD probe needs its own, and they expire while a tab is open. The hashing is checked against node:crypto at every length across the padding boundary, against AWS’s published vectors, and against a second implementation for whole requests.

    Credentials are looked up once, at the worker boundary, so the dozen places that build a remote source — the URL box, the compare card, a resolved session config, a restored track row — know nothing about keys, and a key added mid-session applies to the next request rather than the next reload. The worker holds no credential store and gets only the one key the URL it was handed needs.

    The key is in localStorage and readable by any script on the origin. There is no arrangement in which it is not — the app has no backend by construction, which is the same property that makes a dropped file never leave the machine — so what bounds it is the key’s own permissions. The dialog says so, and docs/hosting.md has the two-action IAM policy and the CORS policy a credentialed preflight needs.

  • The example list is a shelf of sessions. Every entry in the landing page’s dropdown is now a .metacooler.json published beside the data it names, and nothing else — three of them, in demo/. A .cool in a dropdown can only ever offer what dropping that .cool offers: a map at its landing view, with nothing beside it. What a first-time reader should meet is the application doing its job, and that has been writable as a file since the export button shipped, so it is written as one.

    Mock microbial community opens the 1/2/4 kb ladder at its finest level, on the genomes table with the largest MAG first. Yeast micro-C opens the balanced 1 kb map at IV:1.0–1.4 Mb with the gene model along both edges, ATAC and its peak calls under the map, Pol II over input and its peaks beside it, and 1,506 loop calls drawn on it. Yeast chromatin and transcription opens the same assembly with no contact map at all: nine lanes over chromosome IV, MNase and ATAC in one group on pinned axes, the two RNA strands sharing one axis with the reverse strand read downwards, and the strain’s own sequence as GC content above them all.

    The sequence is published with them: S288c_syn40-XIII.fa, the map’s sixteen sequences residue for residue — III at 315,525 and XIII-syn40 at 964,431, no Mito — so the lane is the genome the map is of rather than the reference it was lifted from. Both yeast sessions draw it, on both edges beside the map. It is deliberately not in the “load the tracks published with this map” button: a FASTA is read whole and held whole, and twelve megabytes of residues arriving from a button labelled that way would be a surprise.

  • A session can be opened by URL. A .json in the picker’s URL box, or in the list, is fetched, parsed and applied — the published counterpart of dropping one, and the same code after the first line. Which is what makes a session something you can link to rather than only send.

  • A published session names its files relatively. An exported session names the absolute URLs the tab it came from was reading; one that ships with its data cannot, because the same directory is served from / in dev, /<repo>/ on Pages and a third origin in the desktop build. Relative names are now resolved against the session’s own URL on the way in, the way an <img src> in a page is, so one directory of sessions works in all three. An exported session is untouched by it, and a file may mix the two.

  • A track’s name is edited where it is drawn. The label pass measured every name and forgot it, exactly as it once did the value range; it hands the boxes back now, and a press on one opens a caret over it — in the 1D browser and beside the map, one editor for both. A name commits to the one track it names, unlike the range, which commits to every track in the lane because a lane draws one axis.

  • A map is a thing you can put down. Compare becomes Contact maps, at the top of the Annotations column, with one row per open map wearing the same mark-name-eye-× head a track’s row wears. The eye on the first map hides the matrix — which is the 1D genome browser, the same tracks against the same genome with the picture taken out — and the file stays open behind it, so the trip back costs one press. The eye on the second takes the lower-left triangle away without putting its reader down. V does the same from anywhere, as does an icon-and-word button on the title line beside the search box. The window comes across: hiding a map opens the browser where the map was looking, and showing it again restores the parked view exactly, down to the colour clip.

    The one-way door has left the File card, where it was the only way out of the matrix: it is the × beside the name now, next to the eye it is not. A browser started from a track gets the card too, with an empty slot to drop a map on.

  • The box beside a map finds genes. It only ever spoke coordinates, so typing GAL4 navigated to region=GAL4 and came back with “unknown label” — on a whole-genome map of a mammal, which is three pixels per chromosome arm and is precisely where naming a gene is the only way to get anywhere. The scan is the browser’s, shared, and it returns numbers: the browser writes 1-based loci and a map reads 0-based half-open regions.

  • Coordinates on a 2D drag. A marquee on the map drew a shaded square and nothing else. It writes both ends and the width now, on both axes — a square in pixels is two different spans in base pairs the moment the axes are two sequences — each chip outside its own edge so it never covers what it measures. On the diagonal the two axes say the same thing and it is said once. The chips and the region that opens come from one derivation, so they cannot disagree.

  • The line under a map says what window it is showing. 3,786 × 3,786 bins · effective resolution: 600 bp is two facts about the block and none about the picture. It carries Pf3D7_05_v3:1,001-24,600 · 23.6 kb now, in the browser’s own words and grammar, updated in the same frame the map is drawn in.

  • One text size for every picture. A pair of presses in both Plot cards scales every canvas font in the app together — track names, value ranges, ruler coordinates, gene names, block labels, colourbar ticks, export captions — and every line height, click box and label band that was measured against one. A multiplier rather than a size, because the sixteen sizes written down across the app were each chosen against the thing they sit on.

  • Shrink, next to stretch. Stretch answers “use the space I have”; shrink answers “give it back, to the size I already chose for one of these” — the shortest lane on screen rather than the floor. All tracks at once, and within a group beside the button that levels a group to its tallest.

  • A name column with a width of its own. Its edge is draggable, and a double click hands it back to the names. A name too long for a pinned column wraps to a second line, breaking where the writer said a part ends, and is cut in the middle — Yeast_…-input — when even two lines will not hold it, because the ends of an experiment’s name are where the information is.

  • The Aesthetics column is four cards, named after what they draw on. View is Map and holds everything that touches the matrix — the transform, the colourmap, the clip, the refit, and the four overlays that spent a release under a label at the foot of a card about the tracks. Plot is Tracks and holds the lanes and nothing else. Export is its own card at the top of the column, because saving a figure is not a property of the picture: it is what a reader does once everything else is where they want it.

  • ⌘C copies the plot. The figure as a PNG on the clipboard, at the Export card’s resolution, from the system copy chord or from the button beside the save — in the browser and beside a map. It stands down when the keystroke is really about text: with a selection live or the caret in a box, ⌘C is still the browser’s.

  • Shift with the zoom modifier pins the marquee to the diagonal. The press snaps to the nearest bin on the diagonal and the far corner stays on it, so the box a reader draws over a domain is square about the diagonal by construction rather than by a steady hand. The diagonal is where the two axes name the same bin, so an off-diagonal panel gets no false one.

  • Clicking a feature opens its card. Names, position, length, strand, score, the GTF attributes the parser kept — gene id, biotype, source — the coding span and the exons, in a panel that stays open and can be copied out of. The same card in the browser and in a map’s lanes, for the same gesture on the same gene. Zooming to the feature is still there, as a button inside it.

  • The text on every picture starts at 135%, and the track outline starts on. Both are the sizes and marks the renderers were written against, moved to what a contact map read at a desk actually needs; both are one press from where they were, and the identity is still a rung on the ladder.

  • Track height and transparency are steppers, the shape text size already had: a box between two chevrons, a pixel a press for the height and five per cent for the transparency. Text size and transparency share a line, being one question asked twice.

  • Shift over the map reads the diagonal. Held while hovering, the readout snaps onto the nearest bin on the diagonal — the same line the Shift-locked marquee draws along — so a reader can see where on it they are before pressing the button. It answers the question a hand over a contact map is usually asking anyway: contact at this locus with itself is the reference every off-diagonal cell is read against, and at a fitted whole-genome view finding that line by eye is a two-pixel target.

  • Escape abandons a drag, on the map and in the browser. A marquee is committed on release and a release cannot be taken back, so the only way out of a box drawn in the wrong place used to be letting it open somewhere and navigating back. A pan is rewound rather than merely stopped: a pan that ends where the reader changed their mind is the pan happening anyway.

  • The 4C anchor is drawn in whichever direction the hand went. A band of rows and a band of columns name the same stretch of a map read about its diagonal, so insisting on a vertical drag was insisting on a gesture rather than on an answer — and a drag that came out a few degrees the wrong side of vertical gave a one-bin anchor with no hint why. The band on screen follows the same test, so what is drawn is what is taken, and on a rectangular block each direction is resolved through its own axis rather than through the rows.

  • The 4C strip comes out of the map’s height budget. It is furniture above the map in exactly the sense a track stack is furniture below it, and without saying so the map kept its size, the strip was added on top, and the tracks under the pair went below the fold. --lane-top joins --lane-bottom in the plot’s clamp, so turning the strip on shrinks the map by the strip’s height wherever height is what is binding.

  • A virtual 4C profile above the map. A 4C experiment fixes one locus and asks what it touches; a contact map already holds the answer for every locus at once, and pulling one out is arithmetic a reader can do by eye and cannot do accurately. Turn the strip on from the Map card, Shift-drag a band of rows on the picture, and the lane above it draws the sum of every column over that band.

    Computed from the block already on screen and never from a fresh read, which is what makes it honest as well as free: the line is a summary of the pixels above it, at the same pooling, from the same score column, with the same balancing. A separately-read profile could disagree with the picture it is drawn over, and would, at every level but the finest.

    The strip is a canvas of its own in the panel’s grid, exactly as wide as the map’s and drawn through the map’s own column scale — so a peak sits under the column it came from by construction rather than by arrangement. It draws through drawTrack, so it looks like the tracks beside it because it is one, and the fill, the outline and the floor follow the same overlay toggles. Shown and empty until an anchor is drawn, because that empty box is the only place a reader is certainly looking at the moment they need to be told the gesture.

    The anchor is kept as a locus, not as bins or block rows: pan, zoom, change resolution and it still names the same stretch of the same sequence. Panned away from, the lane says so rather than drawing a profile of whatever is at those row numbers now.

  • A score picker where the balanced tickbox was. One dropdown in the Map card naming what the cells are: counts, balanced where the file carries weights, and then every further value column the file’s own /pixels group holds, under its own name. cooler fixes three columns and lets a file carry more — an expected value, a second normalisation, a per-pixel significance — and a viewer that assumed count could not draw any of them, so a file that had gone to the trouble of storing one had no way of saying so. Present on metacools too, where before there was no control at all.

    The choice is one control because it is one question, though balancing is a transform and the others are columns: a tickbox beside a dropdown would be splitting “what am I looking at” across two places. balance stays in the view state exactly as it was, so every link written before this exists still resolves; score carries the rest.

  • The hover card can be put away. A tooltip toggle beside the crosshair, in both Aesthetics rows and over one store, so pressing either moves both. The two things a reader does with a contact map want opposite answers: reading it is the card’s whole reason to exist, and looking at it — framing a figure, showing it to somebody — is a thing the card is directly in the way of. The status line under the window carries the same reading and covers nothing, so turning it off costs a hover no information.

  • The crosshair is offered in both Aesthetics rows. It is the one preference genuinely about both pictures — one pointer, guide lines through the matrix and through every lane — so it is in Map and in Tracks, one key over one store, and pressing either moves both.

  • Select everything the filters left, from the filter row. The tables’ “select all” button has gone, and a tick box sits in the gutter of the filter row instead — directly above the column of row checkboxes, on the line the filter boxes are on. It was already the filtered set the old button acted on, but it sat in the toolbar three controls away from the rows, with nothing about it saying which rows it meant; a reader who had filtered to six of sixteen read the words “select all” and could not tell from the toolbar which they had got. The box says it by where it is, and shows a third state — part-ticked — when some of what is showing is selected. Pressing it when everything showing is ticked unticks it. Rows a filter is hiding are left alone in both directions; “clear”, renamed clear selection, is the one that empties everything.

  • A locus box on the two table views. The landing screen of a cooler is its chromosome table and of a metacool its genome table, and both offered exactly two doors: a whole sequence by clicking its row, or the whole file from the toolbar. A reader who knew the ten kilobases they wanted had to open the chromosome around them and narrow from there — a large read taken to reach a small one, and the box that would have taken them straight there only appeared once they had paid for it. Both tables now carry the box, / opens it, and chrIV:1,200,000-1,210,000 goes to a ten-bin block without the chromosome ever being read. The read path needed nothing: readDenseMatrix has always bounded a query by bin1_offset, so ten bins have always cost ten bins’ worth of pixels — what was missing was somewhere to ask from.

  • Stretch and shrink are a mirrored pair. unfold-vertical against fold-vertical, everywhere the two sit together: two arrows apart and two arrows together, which is what the pair actually does. The old mark was a different glyph that had to be read rather than seen.

  • A track dragged out of the Tracks card onto an edge turns its axis, which a track dragged there by the handle on its own picture has always done. The two gestures mean the same thing and now take the same road; the card’s own location control has always taken it.

  • The demo datasets moved to S3, and the app got 14× lighter. The five published maps and eleven track resources are no longer copied into the bundle out of public/demo/; they live on the lab bucket at https://rsglab-data.s3.eu-west-3.amazonaws.com/demo/, one folder per dataset, uploaded by scripts/publish-demo.sh. A npm run build is 19 MB instead of ~265 MB, and 12 MB of what is left is the source map.

    Size was the smaller half of it. Same-origin hosting waived the CORS requirements of §11 and failed a different one: GitHub Pages gzips application/octet-stream, which is what it served every .metacool and .mcool as, and a Range over a compressed body addresses the octets of the compressed stream. Every demo therefore opened through the whole-object fallback — the probe saw Content-Encoding, refused the ranged path, and the worker downloaded the entire file into memory. The landing page was demonstrating the escape hatch rather than the reader. S3 does not compress that type, so the demos now take the bounded-range path: the one a reader’s own bucket will take. What comes back is the CORS half — a preflight, and Content-Length and Content-Range exposed to JavaScript — which publish-demo.sh configures and then verifies with hosting.md’s three curl calls before it reports success.

    No .metacooler.json changed a line. The names inside a published session are relative and are resolved against the session’s own URL, so moving the folder moved the session with it — which is the property that made the whole change three edits instead of thirty, and the reason a dataset folder stays something you can copy to another bucket.

    The datasets stay checked out in the repository, under demo/, because that directory is both what the sync uploads and what test/unit/demo-examples.test.ts reads: disk and bucket are one tree, so a renamed file is a red CI run rather than a landing page where every choice 404s. They are in git history either way — removing them from the tree would not shrink a clone.

    Two build configs lost a special case with it. The desktop build no longer needs __DEMO_BASE__ pointed at the website (and no longer trips over the Pages 301 that carries no Access-Control-Allow-Origin), and the archived pinned build no longer points its examples back at the site root. Both now read the same URLs the web build does.

  • The 1D browser’s crosshair and readout follow the pointer below the tracks too. The canvas used to stop at the foot of the last lane, so the space under the stack belonged to the column rather than to the plot and the pointer moved over it silently — which is a lot of dead surface on a view with two tracks in a tall window. The canvas now reaches the foot of the column, and pointing anywhere in it names a coordinate and reads every track at it, exactly as pointing at a track does.

    Not before the axis begins. The strip at the left is the handles’ margin and, when it is on, the track names’ column, and neither is a position: the crosshair stops at the axis and the readout says nothing there, rather than naming a base pair while the arrowhead is on a word. That was true of the readout already and is now true of the line as well.

    Only what follows the pointer goes down there. The sequence boundaries and the name column’s own edge stop with the tracks: a boundary is a mark on the tracks, and a rule carrying it through a screen’s worth of blank space is a divider between two things that are not there.

    Only on screen. A figure is a picture of the tracks, and blank paper under them is not part of it, so an exported one is unchanged.

  • The last lane of a stack shows the rule on its resize border, which it never has. The rule is drawn a pixel outside the lane, in the gap before the next one — and the last lane has no gap, because its far edge is the canvas’s own end: the whole two-pixel stroke landed outside the canvas and was clipped away. The drag went on working perfectly, which is the worst of the two failures, because a gesture that is never announced is one a reader finds by accident or not at all. It is now held inside the canvas by its own half-width, which costs every other lane nothing since they are nowhere near either end.

  • A marquee dragged off the map stays alive. The gesture is followed on the window until the button comes up, with every position clamped into the plot, so a corner taken past the edge of the data stops at the edge instead of taking the whole selection with it — and what is opened on release is the box that was drawn rather than wherever the pointer happened to be.

  • The browser’s tooltip stays on screen. It was clamped to the stage, which on a stack of a dozen tracks is several screens tall, so a value read near the foot of the window went under the edge of it. It is flipped and clamped against the window now, and its size is measured once a frame rather than on every pointer move.

  • A session naming a collection at its root kept its workspace. A workspace is stored under the file’s key, and an .mmetacool or .mcool addressed at its root does not stay under the URI it was asked for — it redirects onto one of its entries (§3.4). The tracks, groups and ticked sequences were written under the root’s key and read back under the entry’s, so the map arrived correctly and alone.

  • The Open-a-file dialog opens what the landing page opens. It mounts the same picker with the same example list, and handed its answer to a different door — so an entry from the list arrived there as a file to open, and a published session was reported as a JSON file that is not a cooler.

  • The genomic distance across a cell came back. Anything larger than the picture is read on a coarser grid, and a coarsened run carries no arithmetic origin past its first — so the “N apart” line had gone quiet on exactly the views that are always pooled. It is measured from the same function the two coordinates above it come from. AxisGroup gains unitBin, an identity rather than an origin, which is what a rectangular region view needs to know its two axes are the same contig.

  • Tracks no longer come back doubled after a reload. Ids are minted from one on every page load and a restore re-introduces the previous load’s, so the first file dropped afterwards was handed an id a restored row was already wearing — and two rows under one id move and die together, which is why deleting one of the twins deleted both. Ids handed in are reserved, a row begun twice replaces itself in its own slot, a duplicate already in storage is dropped on the way out, and a file dropped back onto a row this session put down re-attaches it instead of starting a second one from the defaults.

  • Feature names sit below their features beside the map, as they already did in the browser. labelBandOf still refuses the band on a row too short to hold both, so a lane dragged down puts the names back inside the wide features.

Documentation of chromosomix v0.20.0 · built from 40ec1ec · source