654 lines
28 KiB
HTML
654 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>IP Map</title>
|
|
<script src="https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js"></script>
|
|
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css" />
|
|
<link rel="shortcut icon" href="data:image/png;base64,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" />
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
background-color: #111;
|
|
}
|
|
html, body {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
main {
|
|
height: 100%;
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
font-family: sans-serif;
|
|
}
|
|
#top-inputs {
|
|
width: 20rem;
|
|
max-width: calc(100% - 2rem);
|
|
margin-top: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
#range-input {
|
|
flex-grow: 1;
|
|
min-width: 0;
|
|
}
|
|
#range-input, #range-button {
|
|
box-sizing: border-box;
|
|
font-size: 1rem;
|
|
padding: 0.5rem 0.7rem;
|
|
border: 0;
|
|
border-radius: 0.25rem;
|
|
}
|
|
#range-button {
|
|
margin-left: 0.5rem;
|
|
cursor: pointer;
|
|
background-color: rgb(255, 176, 176);
|
|
}
|
|
#map-container {
|
|
flex-grow: 1;
|
|
aspect-ratio: 1 / 1;
|
|
max-height: 100vw;
|
|
margin: auto 0;
|
|
}
|
|
#map {
|
|
width: calc(100% - 2rem);
|
|
height: calc(100% - 2rem);
|
|
margin: 1rem;
|
|
}
|
|
.maplibregl-canvas {
|
|
cursor: pointer;
|
|
}
|
|
.maplibregl-popup {
|
|
max-width: unset !important;
|
|
}
|
|
.maplibregl-popup-content {
|
|
background-color: white;
|
|
color: black;
|
|
font-size: 0.9rem;
|
|
padding: 0.3rem 2.2rem 0.3rem 0.6rem;
|
|
border-radius: 4px;
|
|
}
|
|
.maplibregl-popup-close-button {
|
|
height: 100%;
|
|
width: 2rem;
|
|
margin: 0;
|
|
padding: 0;
|
|
padding-bottom: 3px;
|
|
font-size: 1.3rem;
|
|
color: black;
|
|
}
|
|
.maplibregl-ctrl-top-right {
|
|
display: flex;
|
|
}
|
|
.maplibregl-ctrl-top-right .custom-control {
|
|
align-self: flex-start;
|
|
}
|
|
.maplibregl-ctrl button.maplibregl-ctrl-subnets .maplibregl-ctrl-icon {
|
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' stroke='%23333' stroke-width='1.5' fill='none' viewBox='0 0 29 29'%3E%3Cpath d='m 6 6 v 17 h 17 v -17 h -17 z m 0 8.5 h 17 m -8.5 -8.5 v 17'/%3E%3C/svg%3E");
|
|
}
|
|
.custom-control {
|
|
user-select: none;
|
|
}
|
|
details.custom-control > summary {
|
|
margin: 0;
|
|
padding: 0 0.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
height: 29px;
|
|
cursor: pointer;
|
|
}
|
|
details.custom-control[open] > summary {
|
|
border-bottom: 2px solid black;
|
|
}
|
|
details.custom-control > summary::marker {
|
|
content: "";
|
|
}
|
|
details.custom-control > summary {
|
|
list-style: none;
|
|
}
|
|
details.custom-control > summary::-webkit-details-marker {
|
|
display:none;
|
|
}
|
|
details.custom-control > summary h3 {
|
|
margin: 0;
|
|
padding: 0;
|
|
display: inline;
|
|
font-size: 0.9rem;
|
|
}
|
|
details.custom-control > .button-container {
|
|
max-height: 12rem;
|
|
overflow-y: scroll;
|
|
}
|
|
details.custom-control > .button-container button {
|
|
width: 100%;
|
|
padding: 0 0.5rem;
|
|
}
|
|
details.custom-control > .button-container button.active {
|
|
pointer-events: none;
|
|
}
|
|
.maplibregl-ctrl button {
|
|
color: black;
|
|
font-size: 0.8rem;
|
|
}
|
|
.maplibregl-ctrl button.active {
|
|
background-color: rgb(0 0 0/15%);
|
|
}
|
|
.maplibregl-ctrl button:not(:disabled):hover {
|
|
background-color: rgb(0 0 0/10%);
|
|
}
|
|
.maplibregl-ctrl button:not(:disabled):active {
|
|
background-color: rgb(0 0 0/15%);
|
|
}
|
|
.maplibregl-ctrl p {
|
|
margin: 0.25rem 0.5rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
.maplibregl-ctrl p:empty {
|
|
display: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div id="top-inputs"><input type="text" id="range-input" name="range" minlength="7" maxlength="18" placeholder="1.1.1.1" /><button id="range-button">Jump</button></div>
|
|
<div id="map-container"><div id="map"></div></div>
|
|
</main>
|
|
<script>
|
|
const coordsToHilbert = ({ x, y }) => {
|
|
let rotation = 0
|
|
let reflection = 0
|
|
let index = 0
|
|
for (let b = 15; b >= 0; b--) {
|
|
let bits = reflection
|
|
reflection = (y >>> b) & 1
|
|
reflection |= ((x >>> b) & 1) << 1
|
|
bits = bits ^ reflection
|
|
bits = ((bits >>> rotation) | (bits << (2 - rotation))) & 3
|
|
index |= bits << (b << 1)
|
|
reflection ^= (1 << rotation)
|
|
bits = bits & (-bits) & 1
|
|
while (bits) {
|
|
++rotation
|
|
bits >>>= 1
|
|
}
|
|
if (++rotation >= 2)
|
|
rotation -= 2
|
|
}
|
|
index ^= 0x2aaaaaaa
|
|
for (let d = 1; d < 32; d *= 2) {
|
|
let t = index >>> d
|
|
if (!t) break
|
|
index ^= t
|
|
}
|
|
return index
|
|
}
|
|
|
|
const hilbertToCoords = index => {
|
|
let rotation = 0
|
|
let reflection = 0
|
|
let coord = { x: 0, y: 0 }
|
|
index ^= (index >>> 1) ^ 0x2aaaaaaa
|
|
for (let b = 15; b >= 0; b--) {
|
|
var bits = index >>> (2 * b) & 3
|
|
reflection ^= ((bits >>> (2 - rotation)) | (bits << rotation)) & 3
|
|
coord.y |= (reflection & 1) << b
|
|
coord.x |= ((reflection >>> 1) & 1) << b
|
|
reflection ^= (1 << rotation)
|
|
bits &= (-bits) & 1
|
|
while (bits) {
|
|
bits >>>= 1
|
|
++rotation
|
|
}
|
|
if (++rotation >= 2)
|
|
rotation -= 2
|
|
}
|
|
return coord
|
|
}
|
|
|
|
const normalizeIp = (ip, subnet) => (ip >> (32 - subnet)) << (32 - subnet)
|
|
|
|
const ipToString = (ip, subnet = 32) => {
|
|
const x = normalizeIp(ip, subnet)
|
|
return `${x >> 24 & 0xFF}.${x >> 16 & 0xFF}.${x >> 8 & 0xFF}.${x >> 0 & 0xFF}`
|
|
}
|
|
|
|
const ipFromString = ipStr => {
|
|
const m = ipStr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/)
|
|
if (!m) throw new Error("Invalid IP or Range format")
|
|
const [a, b, c, d] = m.slice(1, 5).map(Number)
|
|
const subnet = m[5] ? Number(m[5]) : 32
|
|
if (a > 255 || b > 255 || c > 255 || d > 255 || subnet > 32) throw new Error("Invalid IP or Range format")
|
|
const ip = normalizeIp(a << 24 | b << 16 | c << 8 | d, subnet)
|
|
return { ip, subnet }
|
|
}
|
|
|
|
// const apiUrl = "https://ipapi.7circles.moe"
|
|
const apiUrl = "http://code.chromatic.garden:8000"
|
|
const defaultVariant = "density"
|
|
const defaultColormap = "jet"
|
|
const tileSize = 256
|
|
const tilesDir = "assets/tiles"
|
|
const tilesId = "ipmap-tiles"
|
|
const privateRangesId = "private-ranges"
|
|
const subnetsId = "subnets"
|
|
|
|
const privateRanges = [
|
|
{ range: "0.0.0.0/8", description: "Current Network Definition" },
|
|
{ range: "10.0.0.0/8", description: "Private Networks" },
|
|
{ range: "100.64.0.0/10", description: "CGNAT Transition" },
|
|
{ range: "127.0.0.0/8", description: "Loopback" },
|
|
{ range: "169.254.0.0/16", description: "Link-Local" },
|
|
{ range: "172.16.0.0/12", description: "Private Networks" },
|
|
{ range: "192.0.0.0/24", description: "DS-Lite Transition" },
|
|
{ range: "192.0.2.0/24", description: "Documentation" },
|
|
{ range: "192.88.99.0/24", description: "IPv4 to IPv6 Transition" },
|
|
{ range: "192.168.0.0/16", description: "Private Networks" },
|
|
{ range: "198.18.0.0/15", description: "Testing" },
|
|
{ range: "198.51.100.0/24", description: "Documentation" },
|
|
{ range: "203.0.113.0/24", description: "Documentation" },
|
|
{ range: "224.0.0.0/4", description: "Multicast (Class D)" },
|
|
{ range: "240.0.0.0/4", description: "Class E" },
|
|
].map(r => ({ ...r, ...ipFromString(r.range) }))
|
|
|
|
const getPrivateRange = searchIp =>
|
|
privateRanges.find(({ ip, subnet }) => normalizeIp(searchIp, subnet) == ip)
|
|
|
|
const canvas = document.createElement("canvas")
|
|
canvas.width = tileSize * devicePixelRatio
|
|
canvas.height = tileSize * devicePixelRatio
|
|
const ctx = canvas.getContext("2d")
|
|
ctx.scale(devicePixelRatio, devicePixelRatio)
|
|
ctx.fillStyle = "white"
|
|
ctx.lineWidth = 3
|
|
ctx.font = "bold 20px sans-serif"
|
|
maplibregl.addProtocol(subnetsId, async (params, abortController) => {
|
|
const [z, y, x] = params.url.split("://")[1].split("/")
|
|
const width = 2 ** z
|
|
const subnet = 2 * z
|
|
const hy = Math.floor(0x10000 * (y / width))
|
|
const hx = Math.floor(0x10000 * (x / width))
|
|
const text = `${ipToString(coordsToHilbert({ x: hx, y: hy }), subnet)}/${subnet}`
|
|
const metrics = ctx.measureText(text)
|
|
const cy = tileSize / 2 + metrics.actualBoundingBoxAscent / 2
|
|
const cx = tileSize / 2 - metrics.actualBoundingBoxRight / 2
|
|
ctx.clearRect(0, 0, tileSize, tileSize)
|
|
ctx.moveTo(0, 0)
|
|
ctx.lineTo(tileSize, 0)
|
|
ctx.lineTo(tileSize, tileSize)
|
|
ctx.lineTo(0, tileSize)
|
|
ctx.lineTo(0, 0)
|
|
ctx.strokeStyle = "white"
|
|
ctx.stroke()
|
|
ctx.strokeStyle = "black"
|
|
ctx.strokeText(text, cx, cy)
|
|
ctx.fillText(text, cx, cy)
|
|
return { data: await new Promise((res, rej) => canvas.toBlob(b => b.arrayBuffer().then(res))) }
|
|
})
|
|
|
|
const map = new maplibregl.Map({
|
|
container: "map",
|
|
attributionControl: false,
|
|
renderWorldCopies: false,
|
|
doubleClickZoom: false,
|
|
dragRotate: false,
|
|
pitchWithRotate: false,
|
|
touchPitch: false,
|
|
style: {
|
|
version: 8,
|
|
sources: {},
|
|
layers: []
|
|
},
|
|
center: [0, 0],
|
|
minZoom: -2,
|
|
maxZoom: 11.499,
|
|
zoom: -2
|
|
})
|
|
map.painter.context.extTextureFilterAnisotropic = undefined
|
|
map.touchZoomRotate.disableRotation()
|
|
|
|
const dataP = fetch(`${tilesDir}/tiles.json`, { cache: "no-store" }).then(res => res.json())
|
|
const privateRangePatternP = map.loadImage("assets/private-range-pattern.png")
|
|
|
|
map.once("style.load", async () => {
|
|
const data = await dataP
|
|
// remove empty keys and sort colormaps alphabetically
|
|
for (const date in data) {
|
|
for (const variant in data[date]) {
|
|
if (data[date][variant].length === 0)
|
|
delete data[date][variant]
|
|
else
|
|
data[date][variant] = data[date][variant].sort()
|
|
}
|
|
if (Object.keys(data[date]).length === 0)
|
|
delete data[date]
|
|
}
|
|
const dates = Object.keys(data).sort()
|
|
if (dates.length === 0) {
|
|
console.error("no data found")
|
|
return
|
|
}
|
|
|
|
let curDate, curVariant, curColormap
|
|
const resetDate = () => curDate = dates[dates.length - 1]
|
|
const resetVariant = () => curVariant = defaultVariant in data[curDate] ? defaultVariant : Object.keys(data[curDate]).sort()[0]
|
|
const resetColormap = () => curColormap = data[curDate][curVariant].includes(defaultColormap) ? defaultColormap : data[curDate][curVariant][0]
|
|
resetDate()
|
|
resetVariant()
|
|
resetColormap()
|
|
|
|
const getTileUrl = () => `${tilesDir}/${curDate}/${curVariant}/${curColormap}/{z}/{y}/{x}.png`
|
|
|
|
map.addSource(tilesId, {
|
|
type: "raster",
|
|
tiles: [getTileUrl()],
|
|
tileSize,
|
|
minzoom: 0,
|
|
maxzoom: 8,
|
|
})
|
|
map.addLayer({
|
|
id: tilesId,
|
|
type: "raster",
|
|
source: tilesId,
|
|
paint: {
|
|
"raster-resampling": "nearest"
|
|
}
|
|
})
|
|
|
|
map.addSource(privateRangesId, {
|
|
type: "geojson",
|
|
data: {
|
|
type: "FeatureCollection",
|
|
features: privateRanges.map(({ range, description, ip, subnet }) => {
|
|
const coordinates = [(() => {
|
|
if (subnet % 2 === 0) {
|
|
const subnetSize = 2 ** (32 - subnet)
|
|
const subnetSideSize = Math.sqrt(subnetSize)
|
|
const subnetsPerSide = 0x10000 / subnetSideSize
|
|
const { x: hx, y: hy } = hilbertToCoords(ip + Math.floor(subnetSize / 2))
|
|
const tx = Math.floor(hx / subnetSideSize)
|
|
const ty = Math.floor(hy / subnetSideSize)
|
|
const mxA = tx / subnetsPerSide
|
|
const mxB = (tx + 1) / subnetsPerSide
|
|
const myA = ty / subnetsPerSide
|
|
const myB = (ty + 1) / subnetsPerSide
|
|
return [
|
|
new maplibregl.MercatorCoordinate(mxA, myA, 0).toLngLat().toArray(),
|
|
new maplibregl.MercatorCoordinate(mxB, myA, 0).toLngLat().toArray(),
|
|
new maplibregl.MercatorCoordinate(mxB, myB, 0).toLngLat().toArray(),
|
|
new maplibregl.MercatorCoordinate(mxA, myB, 0).toLngLat().toArray(),
|
|
new maplibregl.MercatorCoordinate(mxA, myA, 0).toLngLat().toArray(),
|
|
]
|
|
} else {
|
|
const nearestSubnet = subnet + 1
|
|
const subnetSize = 2 ** (32 - nearestSubnet)
|
|
const subnetSideSize = Math.sqrt(subnetSize)
|
|
const subnetsPerSide = 0x10000 / subnetSideSize
|
|
const { x: hxA, y: hyA } = hilbertToCoords(ip + subnetSize / 2)
|
|
const { x: hxB, y: hyB } = hilbertToCoords(ip + subnetSize + subnetSize / 2)
|
|
const txA = Math.floor(hxA / subnetSideSize)
|
|
const tyA = Math.floor(hyA / subnetSideSize)
|
|
const txB = Math.floor(hxB / subnetSideSize)
|
|
const tyB = Math.floor(hyB / subnetSideSize)
|
|
const mxA = Math.min(txA, txB) / subnetsPerSide
|
|
const mxB = (Math.max(txA, txB) + 1) / subnetsPerSide
|
|
const myA = Math.min(tyA, tyB) / subnetsPerSide
|
|
const myB = (Math.max(tyA, tyB) + 1) / subnetsPerSide
|
|
return [
|
|
new maplibregl.MercatorCoordinate(mxA, myA, 0).toLngLat().toArray(),
|
|
new maplibregl.MercatorCoordinate(mxB, myA, 0).toLngLat().toArray(),
|
|
new maplibregl.MercatorCoordinate(mxB, myB, 0).toLngLat().toArray(),
|
|
new maplibregl.MercatorCoordinate(mxA, myB, 0).toLngLat().toArray(),
|
|
new maplibregl.MercatorCoordinate(mxA, myA, 0).toLngLat().toArray(),
|
|
]
|
|
}
|
|
})()]
|
|
|
|
return {
|
|
type: "Feature",
|
|
geometry: {
|
|
type: "Polygon",
|
|
coordinates
|
|
},
|
|
properties: {
|
|
range,
|
|
description
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
const privateRangePatternId = "private-range-pattern"
|
|
map.addImage(privateRangePatternId, (await privateRangePatternP).data)
|
|
map.addLayer({
|
|
id: privateRangesId,
|
|
type: "fill",
|
|
source: privateRangesId,
|
|
paint: {
|
|
"fill-pattern": privateRangePatternId
|
|
}
|
|
})
|
|
|
|
let subnetsVisible = true
|
|
map.addSource(subnetsId, {
|
|
type: "raster",
|
|
tiles: [`${subnetsId}://{z}/{y}/{x}`],
|
|
tileSize,
|
|
minzoom: 0,
|
|
maxzoom: 12,
|
|
})
|
|
map.addLayer({
|
|
id: subnetsId,
|
|
type: "raster",
|
|
source: subnetsId,
|
|
})
|
|
|
|
const updateTileUrl = () => map.getSource(tilesId).setTiles([getTileUrl()])
|
|
|
|
const detailsControl = (name) => {
|
|
const container = document.createElement("details")
|
|
container.className = "maplibregl-ctrl maplibregl-ctrl-group custom-control"
|
|
const header = document.createElement("h3")
|
|
header.textContent = name
|
|
const summary = document.createElement("summary")
|
|
summary.replaceChildren(header)
|
|
const buttonContainer = document.createElement("div")
|
|
buttonContainer.className = "button-container"
|
|
container.replaceChildren(summary, buttonContainer)
|
|
return {
|
|
container,
|
|
setButtons: buttons => buttonContainer.replaceChildren(...buttons.map(({ button }) => button)),
|
|
addControl: () => map.addControl({
|
|
onAdd: () => container,
|
|
onRemove: () => container.parentNode.removeChild(container)
|
|
}, "top-right")
|
|
}
|
|
}
|
|
|
|
const dateControl = detailsControl("Date")
|
|
const variantControl = detailsControl("Variant")
|
|
const colormapControl = detailsControl("Colormap")
|
|
|
|
const dateButtons = dates.map(date => {
|
|
const button = document.createElement("button")
|
|
button.textContent = `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`
|
|
if (curDate === date) button.className = "active"
|
|
return { date, button }
|
|
})
|
|
dateButtons.forEach(({ date, button }) => button.addEventListener("click", () => {
|
|
curDate = date
|
|
if (!(curVariant in data[curDate])) resetVariant()
|
|
if (!data[curDate][curVariant].includes(curColormap)) resetColormap()
|
|
updateTileUrl()
|
|
dateButtons.forEach(({ button }) => button.className = "")
|
|
button.className = "active"
|
|
renderControls()
|
|
}))
|
|
dateControl.setButtons(dateButtons)
|
|
|
|
const renderControls = () => {
|
|
const variants = Object.keys(data[curDate]).sort()
|
|
const variantButtons = variants.map(variant => {
|
|
const button = document.createElement("button")
|
|
button.textContent = variant
|
|
if (curVariant === variant) button.className = "active"
|
|
return { variant, button }
|
|
})
|
|
variantButtons.forEach(({ variant, button }) => button.addEventListener("click", () => {
|
|
curVariant = variant
|
|
if (!data[curDate][curVariant].includes(curColormap)) resetColormap()
|
|
updateTileUrl()
|
|
renderControls()
|
|
}))
|
|
variantControl.setButtons(variantButtons)
|
|
|
|
const colormaps = data[curDate][curVariant]
|
|
const colormapButtons = colormaps.map(colormap => {
|
|
const button = document.createElement("button")
|
|
button.textContent = colormap
|
|
if (curColormap === colormap) button.className = "active"
|
|
return { colormap, button }
|
|
})
|
|
colormapButtons.forEach(({ colormap, button }) => button.addEventListener("click", () => {
|
|
curColormap = colormap
|
|
updateTileUrl()
|
|
colormapButtons.forEach(({ button }) => button.className = "")
|
|
button.className = "active"
|
|
}))
|
|
colormapControl.setButtons(colormapButtons)
|
|
}
|
|
|
|
renderControls()
|
|
|
|
const subnetsControl = document.createElement("div")
|
|
subnetsControl.className = "maplibregl-ctrl maplibregl-ctrl-group custom-control"
|
|
const subnetsButton = document.createElement("button")
|
|
subnetsButton.className = "maplibregl-ctrl-subnets active"
|
|
const subnetsIcon = document.createElement("span")
|
|
subnetsIcon.className = "maplibregl-ctrl-icon"
|
|
subnetsButton.replaceChildren(subnetsIcon)
|
|
subnetsButton.addEventListener("click", () => {
|
|
subnetsVisible = !subnetsVisible
|
|
subnetsButton.classList.toggle("active")
|
|
map.setLayoutProperty(subnetsId, "visibility", subnetsVisible ? "visible" : "none")
|
|
})
|
|
subnetsControl.replaceChildren(subnetsButton)
|
|
map.addControl({
|
|
onAdd: () => subnetsControl,
|
|
onRemove: () => subnetsControl.parentNode.removeChild(subnetsControl)
|
|
}, "top-right")
|
|
|
|
dateControl.addControl()
|
|
variantControl.addControl()
|
|
colormapControl.addControl()
|
|
})
|
|
|
|
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-left")
|
|
|
|
const hoverTextControl = document.createElement("div")
|
|
hoverTextControl.className = "maplibregl-ctrl maplibregl-ctrl-group"
|
|
const hoverTextP = document.createElement("p")
|
|
hoverTextControl.replaceChildren(hoverTextP)
|
|
|
|
const ipFromMouseEvent = e => {
|
|
const { x, y } = maplibregl.MercatorCoordinate.fromLngLat(e.lngLat, 0)
|
|
const ip = coordsToHilbert({ x: Math.floor(0x10000 * x), y: Math.floor(0x10000 * y) })
|
|
const subnet = Math.min(32, Math.round(map.getZoom()) * 2 + 18)
|
|
return { ip, subnet }
|
|
}
|
|
|
|
map.on("mousemove", e => {
|
|
const { ip, subnet } = ipFromMouseEvent(e)
|
|
const ipStr = ipToString(ip, subnet)
|
|
hoverTextP.textContent = subnet < 32 ? `Range: ${ipStr}/${subnet}` : `IP: ${ipStr}`
|
|
})
|
|
|
|
map.addControl({
|
|
onAdd: () => hoverTextControl,
|
|
onRemove: () => hoverTextControl.parentNode.removeChild(hoverTextControl)
|
|
}, "bottom-left")
|
|
|
|
let curPopup
|
|
const setPopup = (pos, ip, subnet = 32) => {
|
|
const isRange = subnet < 32
|
|
const name = isRange ? "Range" : "IP"
|
|
const ipStr = ipToString(ip, subnet)
|
|
const ipText = isRange ? `${ipStr}/${subnet}` : ipStr
|
|
const ipLink = `<a href="https://bgp.tools/prefix/${ipStr}" target="_blank">${ipText}</a>`
|
|
const htmlBase = `${name}: ${ipLink}`
|
|
const privateRange = getPrivateRange(ip)
|
|
const html = privateRange ? `${htmlBase}<br>Part of private range ${privateRange.range}<br>Used for ${privateRange.description}` : htmlBase
|
|
|
|
curPopup?.remove()
|
|
const popup = new maplibregl.Popup({ focusAfterOpen: false }).setHTML(html).setLngLat(pos).addTo(map)
|
|
curPopup = popup
|
|
|
|
if (!isRange && !privateRange) {
|
|
fetch(`${apiUrl}/api/rdns/${ipStr}`).then(res => {
|
|
if (!res.ok)
|
|
throw new Error(`Error fetching rdns for ip ${ipStr}`)
|
|
return res.json()
|
|
}).then(data => {
|
|
const rdns = data?.rdns
|
|
if (rdns) popup.setHTML(`${html}<br>rDNS: ${rdns}`)
|
|
}).catch(_ => {})
|
|
}
|
|
}
|
|
|
|
map.on("click", e => {
|
|
const { ip, subnet } = ipFromMouseEvent(e)
|
|
setPopup(e.lngLat, ip, subnet)
|
|
})
|
|
|
|
const main = document.getElementsByTagName("main")[0]
|
|
main.addEventListener("click", e => {
|
|
if (e.target === main && curPopup) curPopup.remove()
|
|
})
|
|
const rangeInput = document.getElementById("range-input")
|
|
const jumpParam = new URLSearchParams(location.search).get("jump")
|
|
|
|
const jump = range => {
|
|
const { ip, subnet } = ipFromString(range)
|
|
const { x, y } = hilbertToCoords(ip + Math.floor((2 ** (32 - subnet)) / 2))
|
|
const center = new maplibregl.MercatorCoordinate((x + 0.5) / 0x10000, (y + 0.5) / 0x10000, 0).toLngLat()
|
|
const zoom = subnet / 2 - 0.501
|
|
map.jumpTo({ center, zoom })
|
|
if (subnet > 24) setPopup(center, ip, subnet)
|
|
}
|
|
|
|
const setJumpParam = jump => {
|
|
const searchParams = new URLSearchParams(location.search)
|
|
if (jump) searchParams.set("jump", jump)
|
|
else searchParams.delete("jump")
|
|
history.pushState("", "", searchParams.size === 0 ? location.pathname : `?${searchParams}`)
|
|
}
|
|
|
|
try {
|
|
jump(jumpParam)
|
|
rangeInput.value = jumpParam
|
|
} catch(e) {
|
|
setJumpParam(undefined)
|
|
}
|
|
|
|
const jumpToInput = () => {
|
|
try {
|
|
jump(rangeInput.value)
|
|
setJumpParam(rangeInput.value)
|
|
rangeInput.setCustomValidity("")
|
|
} catch (e) {
|
|
rangeInput.setCustomValidity(e.message)
|
|
}
|
|
rangeInput.reportValidity()
|
|
}
|
|
|
|
rangeInput.addEventListener("change", jumpToInput)
|
|
document.getElementById("range-button").addEventListener("click", jumpToInput)
|
|
</script>
|
|
</body>
|
|
</html>
|