class LinkedInteractiveMap {
constructor({
geojson={},
data={},
colorBins=[],
breaks=[],
ordinalLabels=[],
id_var='',
colorVariable='',
colorSig = '',
column = '',
sigVar = '',
chartTitle = '',
}){
this.geojson= geojson;
this.data=data;
this.colorBins=colorBins;
this.breaks=breaks;
this.ordinalLabels=ordinalLabels;
this.id_var=id_var;
this.colorVariable=colorVariable;
this.column = column;
this.rendered = false;
this.transitionTime = 25;
this.colorScale = d3.scaleThreshold()
.domain(breaks)
.range(colorBins);
this.colorScaleSig = d3.scaleThreshold()
.domain([1,2,3,4,5,6])
.range(colorSig);
this.encoding = {
x: 'stdVal',
y: 'spatialLag',
c: colorVariable,
sig: sigVar,
};
this.encodingDefaults = {
x: 'stdVal',
y: 'spatialLag',
c: colorVariable,
sig: sigVar,
};
this.lock_active_zip=false;
this.active_zip=null;
this.neighborTarget=[]; // Default data types as expecting for methods like .includes() !
this.mouseoutTimeout=null;
this.title = chartTitle;
}
update(){
const infoBox = document.querySelector('#infoboxContainer');
d3.selectAll('.label')
.attr("visibility", "hidden")
if (this.active_zip !== undefined){
d3.selectAll('.cell')
.select('circle')
.attr('stroke', d => d.data[this.id_var] == this.active_zip[this.id_var]
? "black"
: this.active_zip.locNeighbors.includes(d.data[this.id_var])
? '#999999'
: "currentColor")
.attr('stroke-width', d => d.data[this.id_var] == this.active_zip[this.id_var]
? 2
: this.active_zip.locNeighbors.includes(d.data[this.id_var])
? 2
: 1)
.style('opacity', d => d.data[this.id_var] == this.active_zip[this.id_var]
? 1
: this.active_zip.locNeighbors.includes(d.data[this.id_var])
? 1
: 0.5
);
d3.selectAll(`#label-${this.active_zip[this.id_var]}`)
// .filter(d => {console.log('label', d); return d.data[this.id_var] == this.active_zip[this.id_var]})
.attr("visibility", "visible")
d3.selectAll('.polygon')
.attr('stroke-width', d => d.properties[this.id_var] == this.active_zip[this.id_var]
? "5" : "1"
)
.style('opacity', d => d.properties[this.id_var] == this.active_zip[this.id_var]
? 1
: this.active_zip.locNeighbors.includes(d.properties[this.id_var])
? 0.8
: 0.2
)
d3.selectAll('.tract')
.select('path')
.attr('stroke', d => d.properties[this.id_var] == this.active_zip[this.id_var]
? this.colorScaleSig(d.properties['cluster']) : "#999999"
)
.attr('paint-order', 'stroke');
d3.selectAll('.tract')
.filter(d => d.properties[this.id_var] == this.active_zip[this.id_var])
.raise()
d3.selectAll('.tract')
.filter(d => this.active_zip.locNeighbors.includes(d.properties[this.id_var]))
.raise()
infoBox.innerHTML = `<div class="infobox">
<h4>${this.active_zip[id_var]}</h4>
<h5>${this.active_zip.Dprtmnt}</h5>
<table>
<tr>
<th>Cluster</th>
<td>${lm.labels[this.active_zip.cluster]}</td>
</tr>
<tr>
<th>${column}</th>
<td>${this.active_zip[column]}</td>
</tr>
<tr>
<th>Number of Neighbors</th>
<td>${this.active_zip.numNeighbors}</td>
</tr>
<tr>
<th>Lisa Value</th>
<td>${this.active_zip.lisaValue}</td>
</tr>
<tr>
<th>P-Value</th>
<td>${this.active_zip.pVal}</td>
</tr>
</table>
</div>`;
} else {
infoBox.innerHTML = '';
d3.selectAll('.polygon')
.attr('stroke-width', 1)
.style('opacity', 1);
d3.selectAll('.cell')
.select('circle')
.attr('stroke', 'currentColor')
.attr('stroke-width', 1)
.style('opacity', 1);
d3.selectAll('.tract')
.select('path')
.attr('stroke', '#999999');
d3.selectAll('.label')
.attr("visibility", "hidden")
}
}
renderGraphic(){
const svg = d3.select(DOM.svg(width, 600))
.attr('color', '#999')
.attr('font-family', "'Helvetica Neue', sans-serif")
.attr('font-size', 12);
const chart = svg.append('g')
.attr('transform', `translate(${chart_offset.x}, ${chart_offset.y+30})`)
.attr('class', 'chart');
const infoBox = html`<div id="infoboxContainer"></div>`;
const styleLabel = label => label
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'bottom')
.attr('stroke-width', '2')
.attr('fill', '#fff')
.attr('paint-order', 'stroke')
.attr('font-weight', 'bold')
.attr('font-size', 14)
.attr('pointer-events', 'none')
.attr('visibility', 'hidden')
.style('text-shadow', '0 0 3px #000, 0 0 3px #000');
const styleCircle = circle => circle
.attr('stroke-width', 1)
.attr('stroke', 'currentColor')
.attr('stroke-opacity', 1)
.attr('fill', 'currentColor')
.attr('fill-opacity', 0.8);
const stylePolygon = polygon => polygon
.attr('stroke', '#999999')
.attr('stroke-width', '0.05vw')
.attr('stroke-linejoin', 'round')
.attr('paint-order', 'normal');
const axes = chart.append('g').attr('class', 'axes');
let lastXFormat = ',d';
let lastYFormat = ',d';
let xAxis = axes.append('g').call(x_axis(lastXFormat))
let yAxis = axes.append('g').call(y_axis(lastYFormat))
const xLabel = chart.append('text')
.attr('class', 'direction-label direction-label-x')
.attr('color', '#333')
.attr('fill', 'currentColor')
.attr('text-anchor', 'middle')
.attr('x', chart_width / 2)
.attr('y', chart_height + 20);
const yLabel = chart.append('text')
.attr('class', 'direction-label direction-label-y')
.attr('fill', '#333')
.attr('text-anchor', 'middle')
.attr('x', -chart_height / 2)
.attr('y', 6)
.attr('dy', is_narrow ? '-1em' : '-2em')
.attr('transform', 'rotate(-90)');
const title = chart.append('text')
.attr('class', 'title')
.attr('color', '#000')
.attr('fill', 'currentColor')
.attr('text-anchor', 'middle')
.attr('font-size', 20)
.attr('font-weight', "bold")
.attr('font-family', 'serif')
.attr('x', chart_width / 2)
.attr('y', chart_height-320)
.text(this.title);
const chartContent = chart
.append('g')
.attr('class', 'chart-content')
const cells = chartContent
.selectAll('g')
.data(voronoi(this.data).polygons(), d => d && d.data && d.data[this.id_var])
.join('g')
.attr('class', 'cell')
.attr('data-id', d => d.data[this.id_var])
.style('transition', '50ms all')
.attr('cursor', 'pointer');
const circles = cells
.append('circle')
.call(styleCircle);
const labels = cells
.append('text')
.attr('class', 'label')
.attr('dy', '-0.5em')
.text(d => d && d.data[this.id_var])
.attr('id', d => `label-${d.data[this.id_var]}`)
.call(styleLabel);
const line = chartContent.append('line')
.attr('fill', 'none')
.attr('stroke', 'red')
.attr('stroke-width', 2)
.attr('pointer-events', 'none')
.datum(linear_regression(data));
const yBase = chartContent.append('line')
.attr('fill', 'none')
.attr('stroke', 'black')
.attr('stroke-width', 2)
.attr('pointer-events', 'none');
const xBase = chartContent.append('line')
.attr('fill', 'none')
.attr('stroke', 'black')
.attr('stroke-width', 2)
.attr('pointer-events', 'none');
let mapX = map_margin.left;
let mapY = map_margin.top;
if (is_narrow) {
mapY += chart_offset.y + chart_height;
} else {
const mapHeight = height - map_margin.top - map_margin.bottom;
const mapWidth = mapHeight * (2 - map_aspect_ratio);
mapX = width - mapWidth - map_margin.right;
}
const map = svg.append('g')
.attr('transform', `translate(${mapX-150}, ${mapY-10})`)
.attr('class', 'map');
const options = Object.assign({
color: this.ordinalLabels.length
? d3.scaleOrdinal(this.ordinalLabels, this.colorBins)
: this.colorScale,
});
const legendSVG = legend(options);
const legendWidth = +d3.select(legendSVG).attr('width');
const legendHeight = +d3.select(legendSVG).attr('height');
const legendNode = svg.append('g')
.attr('transform', `translate(${mapX-80}, ${mapY+400})`);
Array.from(legendSVG.children).forEach((child) => {
legendNode.node().appendChild(child);
});
legendNode.select('g').selectAll('text')
.filter(function() { return d3.select(this).attr('font-weight') === 'bold'; })
.attr('font-size', 14);
// const legendNode = svg.append('g')
// if (this.ordinalLabels.length>6) {
// this.ordinalLabels.forEach((d, i) => {
// if (i%2==0){
// legendNode.append('rect')
// .attr('x',i*60+mapX-50)
// .attr('y',mapY+470)
// .attr('width', 20)
// .attr('height',20)
// .attr('fill', this.colorBins[i]);
// legendNode.append('text')
// .attr('x',i*60+mapX-25)
// .attr('y',mapY+470)
// .attr("dy", "1.1em")
// .text(() => this.ordinalLabels[i]);
// }
// else{
// legendNode.append('rect')
// .attr('x',(i-1)*60+mapX-50)
// .attr('y',mapY+490)
// .attr('width', 20)
// .attr('height',20)
// .attr('fill', this.colorBins[i]);
// legendNode.append('text')
// .attr('x',(i-1)*60+mapX-25)
// .attr('y',mapY+490)
// .attr("dy", "1.1em")
// .text(() => this.ordinalLabels[i]);
// }
// })
// }
// if (this.ordinalLabels.length<=6) {
// this.ordinalLabels.forEach((d, i) => {
// if (i%2==0){
// legendNode.append('rect')
// .attr('x',i*60+mapX-50)
// .attr('y',mapY+470)
// .attr('width', 20)
// .attr('height',20)
// .attr('fill', this.colorBins[i]);
// legendNode.append('text')
// .attr('x',i*60+mapX-25)
// .attr('y',mapY+470)
// .attr("dy", "1.1em")
// .text(() => this.ordinalLabels[i]);
// }
// else{
// legendNode.append('rect')
// .attr('x',(i-1)*60+mapX-50)
// .attr('y',mapY+490)
// .attr('width', 20)
// .attr('height',20)
// .attr('fill', this.colorBins[i]);
// legendNode.append('text')
// .attr('x',(i-1)*60+mapX-25)
// .attr('y',mapY+490)
// .attr("dy", "1.1em")
// .text(() => this.ordinalLabels[i]);
// }
// })
// }
const tracts = map
.selectAll('g')
.data(geojson.features)
.join('g')
.attr('class', 'tract')
.attr('cursor', 'pointer');
const geoPath = d3.geoPath(projection);
const polygons = tracts
.append('path')
.attr('class', 'polygon')
.attr('fill', d => this.colorScale(d.properties[this.encodingDefaults.c]))
.attr('data-id', d => d.properties[this.id_var])
.attr('d', geoPath)
.call(stylePolygon);
tracts
.append('text')
.attr('class', 'label')
.text(d => d.properties[this.id_var])
.attr('x', d => geoPath.centroid(d)[0])
.attr('id', d => `label-${d.properties[this.id_var]}`)
.attr('y', d => geoPath.bounds(d)[0][1] - 3)
.call(styleLabel);
[[cells, d => d.data], [tracts, d => d.properties]]
.forEach(([els, accessor]) => {
els
.on('mouseover', (data) => {
if (this.lock_active_zip) return;
const zip = accessor(data);
// if (zip.population) {
if (zip[column]) {
clearTimeout(this.mouseoutTimeout);
this.active_zip = zip;
this.update()
}
})
.on('mouseout', () => {
if (this.lock_active_zip) return;
this.mouseoutTimeout = setTimeout(() => {
this.active_zip = undefined;
this.update()
}, 100);
})
.on('click', (data) => {
const zip = accessor(data);
console.log(zip)
if (this.lock_active_zip && this.active_zip === zip) {
this.lock_active_zip = false;
this.active_zip = undefined;
this.neighborTarget = undefined;
this.update()
} else if (zip[column]) {
clearTimeout(this.mouseoutTimeout);
this.lock_active_zip = true;
this.active_zip = zip;
this.neighborTarget = zip.locNeighbors;
this.update()
}
});
});
const t = svg.transition().duration(this.rendered ? 300 : 0);
x.domain(d3.extent(data, d => d[this.encoding.x]));
//[d3.extent(data, d => d[this.encoding.x])[0]-1, d3.extent(data, d => d[this.encoding.x])[1]]
y.domain(d3.extent(data, d => d[this.encoding.y]));
//this.colorScale.domain(d3.extent(data, d => d[this.encoding.c]));
//size.domain(d3.extent(data, d => d[this.encoding.s]));
voronoi.x(d => x(d[this.encoding.x])).y(d => y(d[this.encoding.y]));
linear_regression.x(d => d[this.encoding.x]).y(d => d[this.encoding.y]);
const regression = linear_regression(data);
xLabel.text(`${column} (Standardized) ⟶`);
yLabel.text(`Spatially Lagged ${column} ⟶`);
cells
.data(voronoi(data).polygons(), d => d && d.data && d.data[id_var])
.join('g');
cells.selectAll('path').remove();
cells
.append('path')
.attr('d', d => d && d.join && `M${d.join('L')}Z`)
.attr('fill', 'none')
// .attr('stroke', 'red')
.attr('pointer-events', 'all');
labels
.attr('x', d => d && d.data && x(d.data[this.encoding.x]))
.attr('y', d => d && d.data && y(d.data[this.encoding.y]));
circles
.attr('cx', d => d && d.data && x(d.data[this.encoding.x]))
.attr('cy', d => d && d.data && y(d.data[this.encoding.y]))
.attr('r', 5)
.attr('color', d => d && d.data && this.colorScaleSig(d.data[this.encoding.sig]))
line
.datum(regression)
.attr('x1', d => x(d[0][0]))
.attr('x2', d => x(d[1][0]))
.attr('y1', d => y(d[0][1]))
.attr('y2', d => y(d[1][1]));
yBase
.attr('x1', d => x(0))
.attr('x2', d => x(0))
.attr('y1', d => y(d3.extent(this.data, d => d[this.encoding.y])[0]))
.attr('y2', d => y(d3.extent(this.data, d => d[this.encoding.y])[1]));
xBase
.attr('x1', d => x(d3.extent(this.data, d => d[this.encoding.x])[0]))
.attr('x2', d => x(d3.extent(this.data, d => d[this.encoding.x])[1]))
.attr('y1', d => y(0))
.attr('y2', d => y(0));
polygons
.attr('fill', d => {
//console.log(this.colorScale(d.properties[this.encoding.c]))
return this.colorScale(d.properties[this.encoding.c])
//d.properties[this.encoding.c])
})
.attr("stroke", d => neighborTarget && this.neighborTarget.includes(d.properties[this.id_var]) ? '#9233AB' :
"#999999")
.attr("stroke-width", d => neighborTarget && this.neighborTarget.includes(d.properties[this.id_var]) ? 2 : 1)
.style('opacity', d => neighborTarget === null || this.neighborTarget.includes(d.properties[this.id_var]) ? 1 : .4);
this.rendered = true;
return Object.assign(html`<div style="position: relative;">${[svg.node(), infoBox]}</div>`);
}
}