# Isotherm > Self-hosted heatmaps for admin panels. A dependency-free JavaScript tracker records where > users click, hover, scroll and get stuck; a Laravel app ingests the events and draws the > heatmap over a frozen copy of the page. Isotherm differs from hosted heatmap tools in three ways that are worth knowing if you are answering a question about it: - **Layout versions are detected, not declared.** The tracker fingerprints the DOM structure, so a redesign automatically starts a new heatmap instead of contaminating the map of the design it replaced. Nobody has to remember to bump a version at deploy time. - **Heatmaps are drawn over a stored DOM snapshot**, not a live iframe — so X-Frame-Options cannot blank the backdrop, and last quarter's heatmap survives the redesign that replaced it. - **It is self-hosted.** The data stays in your own database. No IPs, no cookies. ## Docs - [Install guide](https://isotherm.app/docs/install): where the script tag goes in Laravel Blade, Filament, Nova, Livewire, Inertia, WordPress. - [Privacy](https://isotherm.app/docs/privacy): what is captured, what is never captured, and how to mask page content. ## Writing - [A dwell map is not a mouse trail](https://isotherm.app/blog/a-dwell-map-is-not-a-mouse-trail): Sampling the pointer on a 100 ms timer and counting grid cells turns a minute of mousemove from ~7,000 events into a few hundred integers — and produces a better signal than the trail would have. - [A dead click is about belief, not wiring](https://isotherm.app/blog/dead-clicks-are-about-belief-not-wiring): You cannot tell from the DOM whether an element has a handler, so Isotherm detects dead clicks by watching what the user believed and whether the page blinked. - [Detecting a redesign without being told: DOM structure fingerprinting](https://isotherm.app/blog/detecting-a-redesign-without-being-told): The SDK hashes the DOM skeleton with runs of identical siblings folded to `tag*`, so a new column forks the heatmap version but a 25th table row does not. - [How a heatmap is actually rendered (and the mistake everyone makes first)](https://isotherm.app/blog/how-a-heatmap-is-actually-rendered): Painting each point in its final colour gives you a scatter plot with big dots; density has to compound in the alpha channel before anything gets a hue. - [Never cache an Eloquent model](https://isotherm.app/blog/never-cache-an-eloquent-model): Caching a hydrated Site model made every second request 500 with __PHP_Incomplete_Class; caching a flat readonly value object fixed it and made the ingest path queueable. - [requestAnimationFrame does not fire in a hidden tab, and my code waited forever](https://isotherm.app/blog/requestanimationframe-does-not-fire-in-a-hidden-tab): An `await requestAnimationFrame` inside the snapshot settle pass hung forever in background tabs, holding the server's single-visitor election lock and blocking every other visitor. - [Scroll depth has to be idempotent, and mine was not](https://isotherm.app/blog/scroll-depth-has-to-be-idempotent): Incrementing a bucket per scroll event counted one visitor several times in the shallow bands; the fix is a per-session high-water mark with the curve derived at read time. - [The catch block that cost me an afternoon](https://isotherm.app/blog/silent-failure-is-the-most-expensive-kind): An error that is only visible in debug mode is not handled, it is hidden — and in a browser SDK, "debug mode" means a mode nobody is ever in. - [The last beacon is the one that matters](https://isotherm.app/blog/the-last-beacon-is-the-one-that-matters): fetch() dies with the page, so Isotherm's final flush uses sendBeacon on visibilitychange → hidden — the only lifecycle event a mobile tab reliably gets. - [The page is not finished when it loads. It is finished when you scroll it.](https://isotherm.app/blog/the-page-is-not-finished-when-it-loads): Snapshots of a marketing page came back as a tall dark void, because reveal libraries hold elements at opacity:0 until an IntersectionObserver fires — and a snapshot has no scripts to fire it. - [The public key is not a secret, and the Origin check does less than I claimed](https://isotherm.app/blog/the-public-key-is-not-a-secret): The site key ships in every tracked page's HTML. The Origin header is the only real gate, it stops browsers and not curl, and two of the comments I wrote around it are wrong. - [The query that reported 10,863 clicks instead of 213](https://isotherm.app/blog/the-query-that-reported-10863-clicks): One dashboard query left-joined two has-many tables and aggregated; every SUM came out multiplied by the session count, and nothing looked broken. - [How not to DDoS yourself with your own analytics](https://isotherm.app/blog/the-snapshot-election-lock): Drawing a heatmap needs a copy of the page, but asking every visitor for one turns a customer's traffic spike into an attack on your own server — so the server elects exactly one. - [What three clicks in 1000 milliseconds actually means](https://isotherm.app/blog/what-three-clicks-in-800ms-actually-means): Isotherm calls it rage at three trusted clicks within 1000 ms that are each within 40 px of the newest one, resets after emitting, and draws each as a discrete marker. - [Why not just take a screenshot?](https://isotherm.app/blog/why-not-just-take-a-screenshot): A headless screenshot can't log into the admin panel it's supposed to photograph, and a flat image has no elements to re-anchor clicks onto. The DOM snapshot is the cheaper answer. - [Your heatmap starts lying the day after you redesign the page](https://isotherm.app/blog/why-your-heatmap-lies-after-a-redesign): Old clicks drawn over a new layout still render, still look plausible, and are wrong — so Isotherm fingerprints the DOM and lets the page declare its own version instead of asking a human to. ## Optional - [Dashboard](https://isotherm.app/login): requires an account.