From 7be936bbb47d1dba44567a11e748a2b20ade737f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 06:11:11 +0000 Subject: [PATCH 1/3] Add IP & DNS lookup page Adds /ip with three sections, styled to match the existing tool pages: - Your IP address: IPv4 and IPv6 detected in parallel against protocol-specific endpoints, each geolocated. The two probes are tracked separately so a hanging IPv6 probe never delays the IPv4 result on networks without IPv6. - Your DNS resolvers: resolves a series of unique, uncacheable subdomains and reports which resolvers asked for them, enriched with reverse DNS, provider, and location. - Geolocate an IP: accepts an IPv4/IPv6 address, a domain, or a pasted URL, resolving hostnames over DoH first, and shows full geolocation details with an OpenStreetMap view. Also links the page from the projects list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013wbQRisaMVqCkSNdX6Rn55 --- src/components/port/IPTools.tsx | 588 ++++++++++++++++++++++++++++++++ src/pages/ip.astro | 13 + src/pages/projects.astro | 15 +- 3 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 src/components/port/IPTools.tsx create mode 100644 src/pages/ip.astro diff --git a/src/components/port/IPTools.tsx b/src/components/port/IPTools.tsx new file mode 100644 index 0000000..384719b --- /dev/null +++ b/src/components/port/IPTools.tsx @@ -0,0 +1,588 @@ +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { useEffect, useRef, useState } from "react"; + +interface Geo { + ip: string; + success: boolean; + message?: string; + type?: string; + continent?: string; + country?: string; + country_code?: string; + region?: string; + city?: string; + postal?: string; + latitude?: number; + longitude?: number; + flag?: { emoji?: string }; + connection?: { asn?: number; org?: string; isp?: string; domain?: string }; + timezone?: { id?: string; utc?: string }; +} + +interface Resolver { + ip: string; + geo?: Geo | null; + ptr?: string | null; + loading: boolean; +} + +interface Detection { + ip: string | null; + done: boolean; +} + +// Number of unique subdomains to resolve. Every lookup is answered by ipleak's +// authoritative nameserver, which records whichever resolver asked for it. +const DNS_ROUNDS = 6; +const MAX_RESOLVERS = 16; + +/* -------------------------------------------------------------------------- */ +/* helpers */ +/* -------------------------------------------------------------------------- */ + +const IPV4_RE = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; + +// Expands an IPv6 address to its 32 nibbles, or null if it isn't valid. +function ipv6Nibbles(ip: string): string | null { + if (!ip.includes(":")) return null; + + const halves = ip.split("::"); + if (halves.length > 2) return null; + + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + + let groups: string[]; + if (halves.length === 2) { + const missing = 8 - head.length - tail.length; + if (missing < 0) return null; + groups = [...head, ...Array(missing).fill("0"), ...tail]; + } else { + groups = ip.split(":"); + } + + if (groups.length !== 8) return null; + + const hex = groups.map((group) => group.padStart(4, "0")).join(""); + if (!/^[0-9a-f]{32}$/i.test(hex)) return null; + + return hex.toLowerCase(); +} + +function isIP(value: string) { + return IPV4_RE.test(value) || ipv6Nibbles(value) !== null; +} + +// The .arpa zone whose PTR record holds the reverse DNS name for an IP. +function reverseZone(ip: string): string | null { + if (IPV4_RE.test(ip)) return ip.split(".").reverse().join(".") + ".in-addr.arpa"; + + const nibbles = ipv6Nibbles(ip); + if (!nibbles) return null; + + return nibbles.split("").reverse().join(".") + ".ip6.arpa"; +} + +async function fetchWithTimeout(url: string, ms: number, init?: RequestInit) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ms); + + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +// Keeps concurrent lookups low so we stay well inside the free API rate limits. +function createQueue(limit: number) { + let active = 0; + const waiting: (() => void)[] = []; + + const release = () => { + active--; + waiting.shift()?.(); + }; + + return function enqueue(task: () => Promise): Promise { + return new Promise((resolve, reject) => { + const start = () => { + active++; + task().then(resolve, reject).finally(release); + }; + + if (active < limit) start(); + else waiting.push(start); + }); + }; +} + +const queue = createQueue(4); +const geoCache = new Map>(); +const ptrCache = new Map>(); + +function lookupGeo(ip: string): Promise { + const cached = geoCache.get(ip); + if (cached) return cached; + + const request = queue(async () => { + try { + const response = await fetchWithTimeout(`https://ipwho.is/${encodeURIComponent(ip)}`, 10000); + return (await response.json()) as Geo; + } catch { + // Don't cache a transient failure, so a retry can still succeed. + geoCache.delete(ip); + return null; + } + }); + + geoCache.set(ip, request); + return request; +} + +function lookupPtr(ip: string): Promise { + const cached = ptrCache.get(ip); + if (cached) return cached; + + const request = queue(async () => { + const zone = reverseZone(ip); + if (!zone) return null; + + try { + const response = await fetchWithTimeout(`https://cloudflare-dns.com/dns-query?name=${zone}&type=PTR`, 8000, { headers: { accept: "application/dns-json" } }); + const data = await response.json(); + const answer = data?.Answer?.find((record: { type: number }) => record.type === 12); + return answer?.data ? String(answer.data).replace(/\.$/, "") : null; + } catch { + ptrCache.delete(ip); + return null; + } + }); + + ptrCache.set(ip, request); + return request; +} + +// Resolves a hostname to an address so the lookup box accepts domains too. +async function resolveHostname(hostname: string): Promise { + for (const type of ["A", "AAAA"]) { + try { + const response = await fetchWithTimeout(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=${type}`, 8000, { + headers: { accept: "application/dns-json" } + }); + const data = await response.json(); + const answer = data?.Answer?.find((record: { type: number }) => record.type === (type === "A" ? 1 : 28)); + if (answer?.data) return String(answer.data); + } catch { + // try the next record type + } + } + + return null; +} + +// Both endpoints are asked at once and the first usable answer wins — without an +// IPv6 route the v6 probe just hangs until it times out, so racing keeps the v4 +// result from waiting on it. +async function detectOwnIP(version: 4 | 6): Promise { + const endpoints = version === 4 ? ["https://api.ipify.org?format=json", "https://ipv4.icanhazip.com"] : ["https://api6.ipify.org?format=json", "https://ipv6.icanhazip.com"]; + + const attempts = endpoints.map(async (endpoint) => { + const response = await fetchWithTimeout(endpoint, 6000); + if (!response.ok) throw new Error(`${endpoint} responded ${response.status}`); + + const body = (await response.text()).trim(); + const ip = body.startsWith("{") ? JSON.parse(body).ip : body; + if (!ip || !isIP(ip)) throw new Error(`${endpoint} returned no usable address`); + + return ip as string; + }); + + try { + return await Promise.any(attempts); + } catch { + return null; + } +} + +function randomSession() { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + const bytes = new Uint8Array(40); + + if (typeof crypto !== "undefined" && crypto.getRandomValues) crypto.getRandomValues(bytes); + else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + + return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join(""); +} + +function formatLocation(geo?: Geo | null) { + if (!geo?.success) return null; + + const parts = [geo.city, geo.region, geo.country].filter(Boolean); + if (parts.length === 0) return null; + + return `${geo.flag?.emoji ? geo.flag.emoji + " " : ""}${parts.join(", ")}`; +} + +function formatNetwork(geo?: Geo | null) { + if (!geo?.success || !geo.connection) return null; + + const { asn, org, isp } = geo.connection; + const name = org || isp; + if (!name) return asn ? `AS${asn}` : null; + + return asn ? `${name} (AS${asn})` : name; +} + +/* -------------------------------------------------------------------------- */ +/* components */ +/* -------------------------------------------------------------------------- */ + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function Card({ title, children }: { title: React.ReactNode; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function GeoDetails({ geo }: { geo: Geo }) { + const location = formatLocation(geo); + const network = formatNetwork(geo); + + return ( +
+ {geo.ip}} /> + {geo.type ? : null} + {location ? : null} + {geo.postal ? : null} + {geo.latitude != null && geo.longitude != null ? {`${geo.latitude.toFixed(4)}, ${geo.longitude.toFixed(4)}`}} /> : null} + {network ? : null} + {geo.connection?.isp && geo.connection.isp !== geo.connection.org ? : null} + {geo.timezone?.id ? : null} +
+ ); +} + +function LocationMap({ geo }: { geo: Geo }) { + if (geo.latitude == null || geo.longitude == null) return null; + + const lat = Math.max(-85, Math.min(85, geo.latitude)); + const lon = Math.max(-180, Math.min(180, geo.longitude)); + const bbox = [lon - 0.4, lat - 0.3, lon + 0.4, lat + 0.3].map((value) => value.toFixed(4)).join("%2C"); + + return ( + + ); +} + +function OwnAddress({ version, state, geo, otherFound }: { version: 4 | 6; state: Detection; geo: Geo | null; otherFound: boolean }) { + const location = formatLocation(geo); + const network = formatNetwork(geo); + const { ip, done } = state; + + // Only claim the other protocol is in use if we actually found an address for + // it — otherwise both cards would contradict each other. + const unavailableReason = otherFound + ? version === 6 + ? "Your network reached this page over IPv4 only — no IPv6 connectivity was detected." + : "Your network reached this page over IPv6 only." + : "Could not detect an address. A VPN, firewall, or blocked request may have interfered."; + + return ( +
+

IPv{version}

+ + {!done ? ( +

Detecting…

+ ) : ip ? ( + <> + {/* Long IPv6 addresses get a smaller size so they wrap less awkwardly. */} +

24 ? "text-lg md:text-xl" : "text-xl md:text-2xl"}`}>{ip}

+
+ {location ? : null} + {network ? : null} + {geo?.timezone?.id ? : null} + {geo && !location && !network ? No location data available. : null} +
+ + ) : ( + <> +

Not available

+

{unavailableReason}

+ + )} +
+ ); +} + +export default function IPTools({ children }: { children: JSX.Element }) { + const [ipv4, setIPv4] = useState({ ip: null, done: false }); + const [ipv6, setIPv6] = useState({ ip: null, done: false }); + const [ipv4Geo, setIPv4Geo] = useState(null); + const [ipv6Geo, setIPv6Geo] = useState(null); + + const [resolvers, setResolvers] = useState>({}); + const [dnsDone, setDNSDone] = useState(false); + + const [query, setQuery] = useState(""); + const [searching, setSearching] = useState(false); + const [result, setResult] = useState(null); + const [resolvedFrom, setResolvedFrom] = useState(null); + const [error, setError] = useState(null); + + const seenResolvers = useRef(new Set()); + const [resolverListRef] = useAutoAnimate(); + const [resultRef] = useAutoAnimate(); + + // Detect our own addresses, then geolocate each one. The two protocols are + // tracked separately so a slow IPv6 probe never holds up the IPv4 result. + useEffect(() => { + let cancelled = false; + + const detect = async (version: 4 | 6, setIP: typeof setIPv4, setGeo: typeof setIPv4Geo) => { + const ip = await detectOwnIP(version); + if (cancelled) return; + + setIP({ ip, done: true }); + if (ip) lookupGeo(ip).then((geo) => !cancelled && setGeo(geo)); + }; + + detect(4, setIPv4, setIPv4Geo); + detect(6, setIPv6, setIPv6Geo); + + return () => { + cancelled = true; + }; + }, []); + + // Detect the resolvers actually used by this browser. Each round asks for a + // brand new subdomain, so it can't be served from any cache along the way. + useEffect(() => { + let cancelled = false; + const session = randomSession(); + + const enrich = async (ip: string) => { + const [geo, ptr] = await Promise.all([lookupGeo(ip), lookupPtr(ip)]); + if (cancelled) return; + + setResolvers((previous) => (previous[ip] ? { ...previous, [ip]: { ip, geo, ptr, loading: false } } : previous)); + }; + + (async () => { + for (let round = 1; round <= DNS_ROUNDS; round++) { + if (cancelled) return; + + try { + const response = await fetchWithTimeout(`https://${session}-${round}.ipleak.net/dnsdetection/`, 12000, { cache: "no-store" }); + const data = await response.json(); + const found: string[] = data && !Array.isArray(data) && data.ip ? Object.keys(data.ip) : []; + if (cancelled) return; + + const room = MAX_RESOLVERS - seenResolvers.current.size; + const fresh = found.filter((ip) => !seenResolvers.current.has(ip)).slice(0, Math.max(0, room)); + if (fresh.length === 0) continue; + + fresh.forEach((ip) => seenResolvers.current.add(ip)); + setResolvers((previous) => { + const next = { ...previous }; + fresh.forEach((ip) => (next[ip] = { ip, loading: true })); + return next; + }); + fresh.forEach(enrich); + } catch { + // a failed round just means one fewer sample + } + } + + if (!cancelled) setDNSDone(true); + })(); + + return () => { + cancelled = true; + }; + }, []); + + async function handleLookup() { + const raw = query.trim(); + if (!raw || searching) return; + + setSearching(true); + setError(null); + setResult(null); + setResolvedFrom(null); + + try { + // Accept bare IPs, bracketed IPv6, hostnames, and pasted URLs. + let target = raw.replace(/^\[|\]$/g, ""); + let from: string | null = null; + + if (!isIP(target)) { + let hostname = target.split("/")[0]; + + if (target.includes("://")) { + try { + hostname = new URL(target).hostname.replace(/^\[|\]$/g, ""); + } catch { + setError("Enter a valid IP address or domain name."); + return; + } + } + + if (!isIP(hostname)) { + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(hostname)) { + setError("Enter a valid IP address or domain name."); + return; + } + + const resolved = await resolveHostname(hostname); + if (!resolved) { + setError(`Could not resolve ${hostname}.`); + return; + } + + target = resolved; + from = hostname; + } else { + target = hostname; + } + } + + const geo = await lookupGeo(target); + if (!geo) { + setError("Lookup failed. Please try again."); + return; + } + + if (!geo.success) { + setError(geo.message === "Reserved range" ? `${target} is in a reserved/private range and has no public location.` : `No geolocation data found for ${target}.`); + return; + } + + setResolvedFrom(from); + setResult(geo); + } catch { + setError("Lookup failed. Please try again."); + } finally { + setSearching(false); + } + } + + const resolverList = Object.values(resolvers); + + return ( +
+
+

IP & DNS Lookup

+

See your own address and DNS resolvers, and geolocate any IP or domain.

+
+ +
+ +
+ + +
+
+ + +

+ These are the servers that answered DNS queries for your browser. Detected by resolving unique, uncacheable subdomains and recording which resolvers asked for them. +

+ +
+ {resolverList.length > 0 ? ( +
+ + + + + + + + + + + {resolverList.map((resolver) => ( + + + + + + + ))} + +
IP AddressHostnameProviderLocation
{resolver.ip}{resolver.loading ? "…" : resolver.ptr || "—"}{resolver.loading ? "…" : formatNetwork(resolver.geo) || "Unknown"}{resolver.loading ? "…" : formatLocation(resolver.geo) || "Unknown"}
+
+ ) : dnsDone ? ( +

No resolvers detected. An ad blocker or strict privacy extension may have blocked the test.

+ ) : ( +

Detecting…

+ )} + + {resolverList.length > 0 ? ( +

{dnsDone ? `${resolverList.length} resolver${resolverList.length === 1 ? "" : "s"} detected.` : "Detecting more…"}

+ ) : null} +
+
+ + +
+ setQuery((e.target as HTMLInputElement).value)} + onKeyDown={(e) => e.key === "Enter" && handleLookup()} + > + +
+ +
+ {error ?

{error}

: null} + + {result ? ( +
+ {resolvedFrom ? ( +

+ Resolved {resolvedFrom} to {result.ip}. +

+ ) : null} + + + + +

IP geolocation is approximate — it usually points at the network's registered area, not the device itself.

+
+ ) : null} +
+
+
+
+ ); +} diff --git a/src/pages/ip.astro b/src/pages/ip.astro new file mode 100644 index 0000000..fab99d3 --- /dev/null +++ b/src/pages/ip.astro @@ -0,0 +1,13 @@ +--- +import Layout from "../layouts/Layout.astro"; +import IPTools from "../components/port/IPTools"; +import { Icon } from "astro-icon/components"; +--- + + +
+ + + +
+
diff --git a/src/pages/projects.astro b/src/pages/projects.astro index 01ca388..0225a25 100644 --- a/src/pages/projects.astro +++ b/src/pages/projects.astro @@ -38,7 +38,14 @@ import calopolyIcon from "../assets/calopoly.png"; description="A Minecraft Fabric mod providing a HUD with essential information." tags={["Java", "Minecraft"]} > - {`BetterHUD + {`BetterHUD + +
+ +
+
+
From 1facaeca64863edd8749a1b7bb91a477c4afcfbf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 07:01:50 +0000 Subject: [PATCH 2/3] Link own IP into the lookup, and replace the OSM iframe with a tiled map The lookup box now starts out holding the visitor's own address and shows its result straight away, with a badge marking it as their own. Their geolocation is already cached from detection, so this costs no extra request. Each IP card also gets a "Locate on map" button that retargets the lookup and scrolls down to it. Swaps the OpenStreetMap embed iframe for a map built from CARTO Positron tiles: a pale basemap that suits the site far better than the default mapnik render. Tiles are positioned directly from the Web Mercator projection rather than through a map library, so this stays dependency-free, and the view gets retina tiles, zoom controls, and a marker in the site's accent colour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013wbQRisaMVqCkSNdX6Rn55 --- src/components/port/IPTools.tsx | 268 +++++++++++++++++++++++++------- 1 file changed, 213 insertions(+), 55 deletions(-) diff --git a/src/components/port/IPTools.tsx b/src/components/port/IPTools.tsx index 384719b..a8afb76 100644 --- a/src/components/port/IPTools.tsx +++ b/src/components/port/IPTools.tsx @@ -274,25 +274,130 @@ function GeoDetails({ geo }: { geo: Geo }) { ); } +// Tiles are laid out by hand rather than pulled in through a map library — a +// static, centred view needs nothing more than the Web Mercator projection, and +// it keeps the page dependency-free. +const TILE_SIZE = 256; +const MIN_ZOOM = 2; +const MAX_ZOOM = 16; +const DEFAULT_ZOOM = 11; + +function project(lat: number, lon: number, zoom: number) { + const scale = TILE_SIZE * 2 ** zoom; + const bounded = Math.max(-85.05112878, Math.min(85.05112878, lat)); + const sin = Math.sin((bounded * Math.PI) / 180); + + return { + x: ((lon + 180) / 360) * scale, + y: (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * scale + }; +} + function LocationMap({ geo }: { geo: Geo }) { + const containerRef = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + const [zoom, setZoom] = useState(DEFAULT_ZOOM); + + // Start each new lookup back at the default zoom. + useEffect(() => setZoom(DEFAULT_ZOOM), [geo.ip]); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + + const measure = () => setSize({ width: element.clientWidth, height: element.clientHeight }); + measure(); + + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, []); + if (geo.latitude == null || geo.longitude == null) return null; - const lat = Math.max(-85, Math.min(85, geo.latitude)); - const lon = Math.max(-180, Math.min(180, geo.longitude)); - const bbox = [lon - 0.4, lat - 0.3, lon + 0.4, lat + 0.3].map((value) => value.toFixed(4)).join("%2C"); + const { width, height } = size; + const centre = project(geo.latitude, geo.longitude, zoom); + const left = centre.x - width / 2; + const top = centre.y - height / 2; + const tileCount = 2 ** zoom; + + const tiles: { key: string; src: string; x: number; y: number }[] = []; + if (width > 0 && height > 0) { + for (let tx = Math.floor(left / TILE_SIZE); tx <= Math.floor((left + width) / TILE_SIZE); tx++) { + for (let ty = Math.floor(top / TILE_SIZE); ty <= Math.floor((top + height) / TILE_SIZE); ty++) { + if (ty < 0 || ty >= tileCount) continue; + + // Wrap horizontally so the map doesn't tear at the date line. + const wrapped = ((tx % tileCount) + tileCount) % tileCount; + const subdomain = "abc"[Math.abs(tx + ty) % 3]; + + tiles.push({ + key: `${zoom}/${tx}/${ty}`, + src: `https://${subdomain}.basemaps.cartocdn.com/light_all/${zoom}/${wrapped}/${ty}@2x.png`, + x: tx * TILE_SIZE - left, + y: ty * TILE_SIZE - top + }); + } + } + } return ( - +
+
+ {tiles.map((tile) => ( + + ))} + + {/* Soft edge fade so the tiles melt into the card instead of ending abruptly. */} +
+ +
+ + +
+ +
+ + +
+ + +
+
); } -function OwnAddress({ version, state, geo, otherFound }: { version: 4 | 6; state: Detection; geo: Geo | null; otherFound: boolean }) { +function OwnAddress({ version, state, geo, otherFound, onLocate }: { version: 4 | 6; state: Detection; geo: Geo | null; otherFound: boolean; onLocate: (ip: string) => void }) { const location = formatLocation(geo); const network = formatNetwork(geo); const { ip, done } = state; @@ -321,6 +426,17 @@ function OwnAddress({ version, state, geo, otherFound }: { version: 4 | 6; state {geo?.timezone?.id ? : null} {geo && !location && !network ? No location data available. : null}
+ + ) : ( <> @@ -348,6 +464,9 @@ export default function IPTools({ children }: { children: JSX.Element }) { const [error, setError] = useState(null); const seenResolvers = useRef(new Set()); + const inputTouched = useRef(false); + const prefilled = useRef(false); + const lookupCardRef = useRef(null); const [resolverListRef] = useAutoAnimate(); const [resultRef] = useAutoAnimate(); @@ -419,10 +538,34 @@ export default function IPTools({ children }: { children: JSX.Element }) { }; }, []); - async function handleLookup() { - const raw = query.trim(); + // Seed the lookup box with the visitor's own address so the section opens on + // something meaningful. Their geo result is already cached from the detection + // above, so this costs no extra request. + useEffect(() => { + if (prefilled.current || inputTouched.current) return; + + const own = ipv4.ip ?? (ipv4.done ? ipv6.ip : null); + if (!own) return; + + prefilled.current = true; + setQuery(own); + handleLookup(own); + }, [ipv4, ipv6]); + + function locateAddress(ip: string) { + inputTouched.current = true; + handleLookup(ip); + lookupCardRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + // `input` lets the IP cards drive a lookup directly, rather than going through + // the query state and waiting a render for it to land. + async function handleLookup(input?: string) { + const raw = (input ?? query).trim(); if (!raw || searching) return; + if (input !== undefined) setQuery(raw); + setSearching(true); setError(null); setResult(null); @@ -485,6 +628,7 @@ export default function IPTools({ children }: { children: JSX.Element }) { } const resolverList = Object.values(resolvers); + const isOwnResult = result != null && (result.ip === ipv4.ip || result.ip === ipv6.ip); return (
@@ -496,8 +640,8 @@ export default function IPTools({ children }: { children: JSX.Element }) {
- - + +
@@ -542,46 +686,60 @@ export default function IPTools({ children }: { children: JSX.Element }) {
- -
- setQuery((e.target as HTMLInputElement).value)} - onKeyDown={(e) => e.key === "Enter" && handleLookup()} - > - -
- -
- {error ?

{error}

: null} - - {result ? ( -
- {resolvedFrom ? ( -

- Resolved {resolvedFrom} to {result.ip}. -

- ) : null} - - - - -

IP geolocation is approximate — it usually points at the network's registered area, not the device itself.

-
- ) : null} -
-
+
+ +
+ { + inputTouched.current = true; + setQuery((e.target as HTMLInputElement).value); + }} + onKeyDown={(e) => e.key === "Enter" && handleLookup()} + > + +
+ +
+ {error ?

{error}

: null} + + {result ? ( +
+ {resolvedFrom ? ( +

+ Resolved {resolvedFrom} to {result.ip}. +

+ ) : null} + + {isOwnResult ? ( +

+ + This is your own address +

+ ) : null} + + + + +

IP geolocation is approximate — it usually points at the network's registered area, not the device itself.

+
+ ) : null} +
+
+
); From 731cec26b0b29796c7f6232b5c136925d2f42cbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 09:38:17 +0000 Subject: [PATCH 3/3] Show geolocation as a region rather than a point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pin on exact coordinates claimed a precision the data doesn't have. These lookups resolve to a city or a network's registered area, so the map now draws that area instead of a spot: - The marker becomes a soft haze, blurred so there is no boundary to trace, sized from the real radius the answer implies — tighter when a city came back, wider when only a region or country did. - The view frames itself on that area instead of a fixed street-level zoom, keeping the surrounding context visible. - Coordinates are shown to two decimals, roughly a kilometre; more digits dressed a city-level guess up as a street-level one. - The caption explains it locates the network, not the person. Also adds an opt-in PUBLIC_MAPBOX_TOKEN. Mapbox and Stadia both reject keyless tile requests, so CARTO Positron stays the default; setting a token switches styles and attribution, and tiles fall back to CARTO if the token is rejected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013wbQRisaMVqCkSNdX6Rn55 --- src/components/port/IPTools.tsx | 96 +++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/src/components/port/IPTools.tsx b/src/components/port/IPTools.tsx index a8afb76..ef74d89 100644 --- a/src/components/port/IPTools.tsx +++ b/src/components/port/IPTools.tsx @@ -266,7 +266,11 @@ function GeoDetails({ geo }: { geo: Geo }) { {geo.type ? : null} {location ? : null} {geo.postal ? : null} - {geo.latitude != null && geo.longitude != null ? {`${geo.latitude.toFixed(4)}, ${geo.longitude.toFixed(4)}`}} /> : null} + {/* Two decimals is about a kilometre — past that the extra digits would + dress up a city-level guess as a street-level one. */} + {geo.latitude != null && geo.longitude != null ? ( + {`${geo.latitude.toFixed(2)}, ${geo.longitude.toFixed(2)}`}} /> + ) : null} {network ? : null} {geo.connection?.isp && geo.connection.isp !== geo.connection.org ? : null} {geo.timezone?.id ? : null} @@ -279,8 +283,13 @@ function GeoDetails({ geo }: { geo: Geo }) { // it keeps the page dependency-free. const TILE_SIZE = 256; const MIN_ZOOM = 2; -const MAX_ZOOM = 16; -const DEFAULT_ZOOM = 11; +const MAX_ZOOM = 14; +const EQUATOR_METRES_PER_PIXEL = 156543.03392; + +// Mapbox needs an account, so CARTO's Positron is the default. Set +// PUBLIC_MAPBOX_TOKEN to switch; tiles fall back to CARTO if the token is +// rejected. +const MAPBOX_TOKEN = import.meta.env.PUBLIC_MAPBOX_TOKEN as string | undefined; function project(lat: number, lon: number, zoom: number) { const scale = TILE_SIZE * 2 ** zoom; @@ -293,13 +302,28 @@ function project(lat: number, lon: number, zoom: number) { }; } +function metresPerPixel(lat: number, zoom: number) { + return (EQUATOR_METRES_PER_PIXEL * Math.cos((lat * Math.PI) / 180)) / 2 ** zoom; +} + +// How much ground the answer really covers. These databases resolve an address +// to a city or a network's registered area, never to a street, so the radius +// follows how specific the response managed to be. +function accuracyRadiusKm(geo: Geo) { + if (geo.city) return 25; + if (geo.region) return 75; + if (geo.country) return 250; + return 500; +} + function LocationMap({ geo }: { geo: Geo }) { const containerRef = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); - const [zoom, setZoom] = useState(DEFAULT_ZOOM); + const [zoomOffset, setZoomOffset] = useState(0); + const [mapboxFailed, setMapboxFailed] = useState(false); - // Start each new lookup back at the default zoom. - useEffect(() => setZoom(DEFAULT_ZOOM), [geo.ip]); + // Each new lookup starts framed on its own area again. + useEffect(() => setZoomOffset(0), [geo.ip]); useEffect(() => { const element = containerRef.current; @@ -316,6 +340,19 @@ function LocationMap({ geo }: { geo: Geo }) { if (geo.latitude == null || geo.longitude == null) return null; const { width, height } = size; + const radiusKm = accuracyRadiusKm(geo); + const radiusMetres = radiusKm * 1000; + + // Frame the view on the area itself: pick the zoom that leaves the region + // covering roughly half the shorter side, so the surrounding context stays + // visible and the answer never reads as a single spot. + const shortestSide = Math.min(width || 320, height || 256); + const fittedZoom = Math.log2((EQUATOR_METRES_PER_PIXEL * Math.cos((geo.latitude * Math.PI) / 180) * shortestSide) / (4 * radiusMetres)); + const zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.round(fittedZoom) + zoomOffset)); + + const radiusPixels = radiusMetres / metresPerPixel(geo.latitude, zoom); + const useMapbox = Boolean(MAPBOX_TOKEN) && !mapboxFailed; + const centre = project(geo.latitude, geo.longitude, zoom); const left = centre.x - width / 2; const top = centre.y - height / 2; @@ -333,7 +370,9 @@ function LocationMap({ geo }: { geo: Geo }) { tiles.push({ key: `${zoom}/${tx}/${ty}`, - src: `https://${subdomain}.basemaps.cartocdn.com/light_all/${zoom}/${wrapped}/${ty}@2x.png`, + src: useMapbox + ? `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/${TILE_SIZE}/${zoom}/${wrapped}/${ty}@2x?access_token=${MAPBOX_TOKEN}` + : `https://${subdomain}.basemaps.cartocdn.com/light_all/${zoom}/${wrapped}/${ty}@2x.png`, x: tx * TILE_SIZE - left, y: ty * TILE_SIZE - top }); @@ -350,6 +389,7 @@ function LocationMap({ geo }: { geo: Geo }) { src={tile.src} alt="" draggable={false} + onError={useMapbox ? () => setMapboxFailed(true) : undefined} className="pointer-events-none absolute select-none" style={{ left: tile.x, top: tile.y, width: TILE_SIZE, height: TILE_SIZE }} /> @@ -358,15 +398,23 @@ function LocationMap({ geo }: { geo: Geo }) { {/* Soft edge fade so the tiles melt into the card instead of ending abruptly. */}
-
- - -
+ {/* The answer is a region, not a point, so it's drawn as a haze that + fades out with no border to trace — there is no exact spot to mark. */} +
+
+ + Likely somewhere in this area (~{radiusKm} km) +
+ @@ -734,7 +793,10 @@ export default function IPTools({ children }: { children: JSX.Element }) { -

IP geolocation is approximate — it usually points at the network's registered area, not the device itself.

+

+ IP geolocation resolves to a region, not an address. It reflects where the network block is registered, which can sit far from whoever is using it — and a VPN + or mobile carrier moves it further still. +

) : null}