Published
Edited
Jan 7, 2022
2 stars
Insert cell
Insert cell
Insert cell
Insert cell
viewof poles = {
const initial = d3.range(108).map(d => {
return {
x: randomNumber(0, width * 2),
y: randomNumber(10, height),
positive: (d % 10 == 3) ? false : true,
str: (d % 10 == 3) ? 0.1 : 0.02
}
})
let svg = d3.create("svg")
.attr("viewBox", [0, 0, width * 2, height * 2])
.attr("width", width * 2)
.attr("height", height * 2)
let fieldG = svg.append('g')
let polesG = svg.append('g')
let node = svg.node();
node.value = initial
let render = (newPoles) => {
console.log(newPoles[0].x, newPoles[0].y)
let fieldPerGridCell = gridCellFactory({
cellValue: (col, row) => {
let x = col * resolution
let y = row * resolution

return fieldDirectionSampler(x, y, node.value)
}
})
gridLayer(fieldG, fieldPerGridCell)
polesLayer(polesG, newPoles)
}
render(node.value)
return node
}
Insert cell
Insert cell
fieldDirectionSampler = (x, y, poles) => {
// Setting up the noise
let noise = perlin.gen(x / width * perlinScale, y / height * perlinScale) * 2 * Math.PI
let noiseStr = 0.000002

// Noise at given point
let perlinBackground = {
sumx: Math.cos(noise) * noiseStr,
sumy: Math.sin(noise) * noiseStr
}

// Stack the influence of the poles on top
let sumfield = poles.reduce((memo, pole) => {
let dx = (x - pole.x)
let dy = (y - pole.y)
let r = Math.hypot(dx, dy)
let magnitude = pole.str / Math.pow(r, 1.7)
let theta = vectorForGivenPositionAndPole(x, y, pole.x, pole.y, pole.positive)

// Influence of each pole at the given point
let fieldx = magnitude * Math.cos(theta)
let fieldy = magnitude * Math.sin(theta)

return {
sumx: memo.sumx + fieldx,
sumy: memo.sumy + fieldy
}
}, perlinBackground) // { sumx: 0, sumy: 0 })

// the Math.PI / 2 at the end "turns" the field 90 degrees so we get the whirlpool directions
return Math.atan2(sumfield.sumy, sumfield.sumx) + Math.PI / 2
}
Insert cell
Insert cell
renderLayers([
{ renderer: circlesLayer, data: startingPoints }
])
Insert cell
startingPoints = {
var sample = poissonDiscSampler(width * 2, height * 2, 5),
samples = [],
s;

while (s = sample()) samples.push({ x: s[0], y: s[1] });
return d3.shuffle(samples)
}
Insert cell
Insert cell
lineTracer = (startLocation, fieldFn, quadtree, incomingParams) => {
let params = {
discontinuationRate: 0.009,
maxLineLength: 200,
...incomingParams
}
let keepGoing = true
let l = [{
theta: fieldFn(startLocation.x, startLocation.y),
...startLocation
}]
const stepSize = resolution * 0.211
while (keepGoing) {

let prev = l[l.length - 1]
if (prev.x < 0 || prev.x > width * 2 || prev.y < 0 || prev.y > height * 2) {
keepGoing = false;
}
let theta = fieldFn(prev.x, prev.y)
let next = {
x: prev.x + stepSize * Math.cos(theta),
y: prev.y + stepSize * Math.sin(theta),
theta
}
// Use quadtree to track where previous lines had been,
// so to not have them run into each other
const hasCloseNeighbour = quadtree.find(next.x, next.y, 3)
if (hasCloseNeighbour) { keepGoing = false; }
l.push(next)

keepGoing = Math.random() > params.discontinuationRate && keepGoing && l.length < params.maxLineLength
}
if (l.length > 1) {
quadtree.addAll(l)
}
return l
}
Insert cell
linesGenerator = (fieldFn, incomingParams) => {
let params = {
minStartSpace: 4,
...incomingParams
}
let quadtree = d3.quadtree()
.x(d => d.x)
.y(d => d.y)
let lines = []
let subset = startingPoints.slice(10)
subset.forEach((point) => {
if (!quadtree.find(point.x, point.y, params.minStartSpace)) {
let l = lineTracer(point, fieldFn, quadtree, params)

if (l.length > 1) { lines.push(l) }
}
})
return lines
}
Insert cell
Insert cell
magneticLines = {
let fieldFn = (x, y) => {
return magneticSampler(x, y, poles)
}
return linesGenerator(fieldFn, { minStartSpace: 1.5, maxLineLength: 27, discontinuationRate: 0.013 })
}
Insert cell
lines = {
let fieldFn = (x, y) => {
return fieldDirectionSampler(x, y, poles)
}
return linesGenerator(fieldFn, {})
}
Insert cell
md`After that, it's basic D3 territory of turning line data into SVG graphics!`
Insert cell
linesRenderer = (g, lines) => {
g.selectAll("polyline")
.data(lines)
.join("polyline")
.attr("stroke", (l, i) => {
//return d3.interpolateWarm((Math.sin(l[0].theta) + 1) / 2)
// return d3.interpolatePuRd(l[0].y / (height * 2))
return '#87c540'
})
.attr("stroke-linecap", "round")
.attr("stroke-width", 2)
.attr("fill", "none")
.attr("points", d => {
return d.map((p) => `${p.x},${p.y}`).join(" ")
})
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
cellRenderer = [
(enter) => {
let g = enter.append("g")
.attr("transform", (d, i) => `translate(${d.x}, ${d.y}) rotate(${d.theta / Math.PI * 180})`)
g.append("line")
.attr("x0", 0)
.attr("y0", 0)
.attr("x1", 3)
.attr("y0", 0)
.attr("stroke", "black")
.attr("stroke-width", 1)
g.append("polygon")
.attr("fill", "black")
.attr("points", "6,0 3,2 3,-2")
g.append("circle")
.attr("fill", "black")
.attr("r", "1")
return g
},
(update) => {
return update
.attr("transform", (d, i) => `translate(${d.x}, ${d.y}) rotate(${d.theta / Math.PI * 180})`)
}
]
Insert cell
maxStrokeWidth = 1.5
Insert cell
strokeWidthFn = (i, l) => {
if (i === 0 || i === l - 1) { return 0 }
if (i === 1 || i === l - 2) { return 0.4 }
if (i === 2 || i === l - 3) { return 0.7 }
if (i === 3 || i === l - 4) { return 0.9 }
return 1
}
Insert cell
gridLayer = (g, grid) => {
g.selectAll("g")
.data(grid)
.join(...cellRenderer)
}
Insert cell
polesLayer = (g, poles) => {
g.selectAll("g")
.data(poles)
.join(
(enter) => {
enter
.append("g").attr("transform", (d, i) => `translate(${d.x}, ${d.y})`)
.append("circle")
.attr("fill", d => { return (d.positive ? "red" : "blue") })
.attr("r", d => Math.log(d.str * 100000))
},
(update) => {
update.attr("transform", (d, i) => `translate(${d.x}, ${d.y})`)
}
)
}
Insert cell
circlesLayer = (g, circles) => {
g.selectAll("circle")
.data(circles)
.join("circle")
.attr("cx", d => d.x)
.attr("cy", d => d.y)
.attr("r", "0.75")
.attr("fill", "black")
}
Insert cell
dipoleRenderer = [
(enter) => {
console.log("enter", enter)
enter.attr("transform", (d, i) => `translate(${d.x}, ${d.y})`)
enter.append("circle")
.attr("fill", d => { return (d.positive ? "red" : "blue") })
.attr("r", d => d.str * 100)
},
(update) => {
update.attr("transform", (d, i) => `translate(${d.x}, ${d.y})`)
update
.select("circle")
.attr("fill", d => { return (d.positive ? "red" : "blue") })
.attr("r", d => d.str * 100)
}
]
Insert cell
Insert cell
getNearestFieldPoint = (x, y, grid) => {
let col = Math.max(0, Math.min(Math.round(x / resolution), grid[0].length - 1))
let row = Math.max(0, Math.min(Math.round(y / resolution), grid.length - 1))

return grid[row][col]
}
Insert cell
Insert cell
resolution = width * resolutionRatio
Insert cell
resolutionRatio = 0.05
Insert cell
width = 400
Insert cell
height = 400
Insert cell
perlin = new tumult.Perlin2("tyler_hobbs")
Insert cell
tumult = require('https://unpkg.com/tumult/dist/tumult.min.js')
Insert cell
randomNumber = (min, max) => {
return Math.floor((max - min + 1) * Math.random() + min)
}
Insert cell
d3 = require("d3@5")
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more