block by burritojustice 5938a5bee73792f7db101d4be87f056c

SF strip map

Full Screen

A version of this labeled strip map by the all-powerful Veltman, but of San Francisco instead of California.

SF GeoJSON via https://data.sfgov.org/Geographic-Locations-and-Boundaries/SF-Find-Neighborhoods/pty2-tcw4 and a lot of QGIS munging. GeoJSON now available as an alt_geometry in Who’s on First.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <style>

    path {
      fill: none;
      stroke-width: 2px;
      stroke-linejoin: round;
    }

    text {
      font: 14px Helvetica, Arial, sans-serif;
      text-anchor: end;
    }

    .state {
      stroke: #999;
      stroke-width: 1px;
      fill: papayawhip;
    }

    .simplified {
      stroke: #de1e3d;
      stroke-width: 2px;
      stroke-dasharray: 8,8;
    }

    .zone {
      stroke: #0eb8ba;
    }

    .hidden {
      display: none;
    }

  </style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js"></script>
<script src="warper.js"></script>
<script src="simplify.js"></script>
<script>

var stripWidth = 100;

var points = [
  { name: "Ft. Funston", coordinates: [-122.501464,37.709899] },
  { name: "SF Zoo", coordinates: [-122.506528,37.732643] },
  { name: "The Sunset", coordinates: [-122.509789,37.753140] },
  { name: "Golden Gate Park", coordinates: [-122.510648,37.765897] },
  { name: "The Richmond", coordinates: [-122.511506,37.774989] },
  { name: "Lands End", coordinates: [-122.506356,37.787471] },
  { name: "Sea Cliff", coordinates: [-122.487216,37.789574] },
  { name: "GGB", coordinates: [-122.477174,37.810733] },
  { name: "Crissy Field", coordinates: [-122.453570,37.806258] },
  { name: "Fort Mason", coordinates: [-122.430997,37.807207] },
  { name: "Fisherman's Wharf", coordinates: [-122.416406,37.809445] },
  { name: "Exploratorium", coordinates: [-122.398859,37.801036] },
  { name: "Ferry Building", coordinates: [-122.393135,37.795508] },
  { name: "Bay Bridge", coordinates: [-122.387545,37.789302] },
  { name: "AT&T Park", coordinates: [-122.387846,37.778449] },
  { name: "The Ramp", coordinates: [-122.386783,37.764557] },
  { name: "Pier 70", coordinates: [-122.381607,37.756457] }, 
  { name: "Islais Creek", coordinates: [-122.394562,37.748254] }, 
  { name: "Heron's Head Park", coordinates: [-122.374885,37.738515] },
  { name: "Hunters Point", coordinates: [-122.365497,37.727467] },
  { name: "Burrito Railgun", coordinates: [-122.361013,37.719982] },
  { name: "Candlestick Point", coordinates: [-122.380357,37.709356] }
 
]; 
 

var projection = d3.geo.conicConformal()
  .parallels([37 + 4 / 60, 38 + 26 / 60])
  .rotate([120 + 30 / 60, -36 - 30 / 60])
  .scale(150000)
  .translate([5300, 4400]);

var line = d3.svg.line();

// Top point
var origin = [50, 100];

d3.json("ca.geojson",function(err,ca){

  // Preproject to screen coords
  ca.coordinates[0] = ca.coordinates[0].map(projection);
  points.forEach(function(point){
    point.coordinates = projection(point.coordinates);
  });
  
  // Move the starting point 54 points earlier (Ft. Funston-ish)
  windBackwards(ca.coordinates[0], 54);

  // Get coastline (54 points longer than before)
  var ls = ca.coordinates[0].slice(0, 1034);

  // Get simplified vertices
  var simplified = simplify(ls, 1500);

  var zones = d3.select("body").append("svg")
    .attr("width", 960)
    .attr("height", 720)
    .selectAll("g")
    .data(getZones(simplified))
    .enter()
      .append("g");

  zones.append("defs")
    .append("clipPath")
    .attr("id",function(d, i){
      return "clip" + i;
    })
    .append("path");

  var inner = zones.append("g")
    .attr("class",function(d, i) {
      return i ? "hidden" : null;
    });

  inner.append("path")
    .attr("class", "state");

  inner.append("line")
    .attr("class", "simplified fade hidden");

  // Put boundary outside so it isn't clipped
  zones.append("path")
    .attr("class", "zone fade hidden");

  // Only put cities in zones they actually fall in
  var cities = zones.selectAll(".city")
    .data(function(d, i){
      return points.filter(function(point){
        if (pip(point.coordinates, d.boundary)) {
          return point.zone = d;
        }
      });
    })
    .enter()
    .append("g")
      .attr("class", "city");

  cities.append("circle")
    .attr("r", 3);

  cities.append("text")
    .text(function(d){
      return d.name;
    })
    .attr("dx", "-0.5em")
    .attr("dy", "0.35em")
	.attr("transform", function(d) {
        return "rotate(-23)" 
        });
  zones.call(update);

  // Step-by-step for demo purposes
  d3.select("body")
    .transition()
    .duration(2000)
    .each("end", clipState)
    .transition()
    .each("end", showLine)
    .transition()
    .each("end", showZones)
    .transition()
    .each("end", move);

  // 1. Clip out the rest of CA
  function clipState() {
    inner.classed("hidden", false)
      .attr("clip-path",function(d, i){
        return "url(#clip" + i + ")";
      });
  }

  // 2. Show the simplified line
  function showLine() {
    inner.select(".simplified")
      .classed("hidden", false);
  }

  // 3. Show the zone boundaries
  function showZones() {
    zones.select(".zone")
      .classed("hidden", false);
  }

  // 4. Rotate/translate all the zones
  function move() {

    warpZones(zones.data());

    // Flip text orientation

    zones.transition()
      .duration(2000)
      .each("end",align)
      .call(update);


  }

  // 5. Warp the zones to rectangles
  function align(z) {

    z.project = function(d){
      return z.warp(z.translate(d));
    };

    z.boundary = z.corners;

    d3.select(this)
      .transition()
      .duration(750)
      .call(update)
      .each("end",fade);
    
    d3.selectAll("text").transition()
      .duration(1000)
      .each("end",function(){
        d3.select(this).transition().duration(500).style("text-anchor", "left")
        	.attr("transform","rotate(-90)")
        	.attr("dx", "-1.0em")
        	.attr("dy", "0.28em");
      });



  }

  // 6. Fade out
  function fade() {

    d3.select(this).selectAll(".fade")
      .transition()
      .duration(500)
      .style("opacity", 0);

  }

  // Redraw
  function update(sel) {

    sel.select(".zone")
      .attr("d",function(d){
        return line(d.boundary.slice(0,4)) + "Z";
      });

    sel.select(".state")
      .attr("d",function(d){
        return d.path(ca);
      });

    sel.select(".simplified")
      .attr("x1",function(d){
        return d.ends[0][0];
      })
      .attr("x2",function(d){
        return d.ends[1][0];
      })
      .attr("y1",function(d){
        return d.ends[0][1];
      })
      .attr("y2",function(d){
        return d.ends[1][1];
      });

    sel.select("clipPath path")
      .attr("d",function(d){
        return line(d.boundary.slice(0,4)) + "Z";
      });

    sel.selectAll(".city")
      .attr("transform",function(d){
        return "translate(" + d.zone.project(d.coordinates) + ")";
      });

  }

});

// Turn a simplified LineString into one group per segment
function getZones(simp) {

  return simp.slice(1).map(function(p, i){

    return {
      boundary: getBoundary(simp[i - 1], simp[i], p, simp[i + 2]),
      ends: [simp[i], p],
      project: id,
      path: d3.geo.path().projection(null)
    };

  });

}

function warpZones(zones) {

  zones.forEach(function(z,i){

    var angle = getAngle(z.ends[0], z.ends[1]),
        anchor = i ? zones[i - 1].ends[1] : origin;

    // Anchor points to end of prev segment
    var translate = [
      anchor[0] - z.ends[0][0],
      anchor[1] - z.ends[0][1]
    ];

    // Get translation/rotation function
    z.translate = translateAndRotate(translate, z.ends[0], angle);

    // Warp the boundary line and the simplified segment
    z.ends = z.ends.map(z.translate);
    z.boundary = z.boundary.map(z.translate);

    var top = bisect(null, z.ends[0], z.ends[1]),
        bottom = bisect(z.ends[0], z.ends[1], null);

    z.corners = [top[0], top[1], bottom[1], bottom[0], top[0]];

    z.corners.push(z.corners[0]);

    // See: //bl.ocks.org/veltman/8f5a157276b1dc18ce2fba1bc06dfb48
    z.warp = warper(z.boundary, z.corners);

    z.project = function(d){
      return z.translate(d);
    };

    z.path.projection(d3.geo.transform({
      point: function(x, y) {
        var p = z.project([x, y]);
        this.stream.point(p[0], p[1]);
      }
    }));

  });

}

function getBoundary(prev, first, second, next) {

  // if prev is undefined, top is perpendicular through first
  // otherwise top bisects the prev-first-second angle
  // if next is undefined, bottom is perpendicular through second
  // otherwise bottom bisects the first-second-next angle
  var top = bisect(prev, first, second),
      bottom = bisect(first, second, next);

  return [top[0], top[1], bottom[1], bottom[0], top[0]];
}

function getAngle(a, b) {

  return Math.atan2(b[1] - a[1], b[0] - a[0]);

}

// Given an anchor point, initial translate, and angle rotation
// Return a function to translate+rotate a point
function translateAndRotate(translate, anchor, angle) {

  var cos = Math.cos(angle),
      sin = Math.sin(angle);

  return function(point) {

    return [
      translate[0] + anchor[0] + ( cos * (point[0] - anchor[0]) + sin * (point[1] - anchor[1])),
      translate[1] + anchor[1] + ( -sin * (point[0] - anchor[0]) + cos * (point[1] - anchor[1]))
    ];

  };

}

// Hacky angle bisector
function bisect(start, vertex, end) {

  var at,
      bt,
      adjusted,
      right,
      left;

  if (start) {
    at = getAngle(start, vertex);
  }

  if (end) {
    bt = getAngle(vertex, end);
  }

  if (!start) {
    at = bt;
  }

  if (!end) {
    bt = at;
  }

  adjusted = bt - at;

  if (adjusted <= -Math.PI) {
    adjusted = 2 * Math.PI + adjusted;
  } else if (adjusted > Math.PI) {
    adjusted = adjusted - 2 * Math.PI;
  }

  right = (adjusted - Math.PI) / 2;
  left = Math.PI + right;

  left += at;
  right += at;

  return [
    [vertex[0] + stripWidth * Math.cos(left) / 2, vertex[1] + stripWidth * Math.sin(left) / 2],
    [vertex[0] + stripWidth * Math.cos(right) / 2, vertex[1] + stripWidth * Math.sin(right) / 2]
  ];
}

// https://github.com/substack/point-in-polygon
// based on //www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
function pip(point, vs) {

  var x = point[0],
      y = point[1],
      inside = false;

  for (var i = 0, j = vs.length - 1; i < vs.length; j = i++) {

      var xi = vs[i][0], yi = vs[i][1];
      var xj = vs[j][0], yj = vs[j][1];

      var intersect = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
      if (intersect) {
        inside = !inside;
      }

  }

  return inside;

}

function id(d) {
  return d;
}

function windBackwards(arr, num) {

  arr.pop();

    for (var i = 0; i < num; i++) {
      arr.unshift(arr.pop());
    }

    arr.push(arr[0]);

  }

d3.select(self.frameElement).style("height", "720px");

</script>
</body>
</html>

simplify.js

// Rough Visvalingam simplification
// Better: https://bost.ocks.org/mike/simplify/
function simplify(points, threshold) {

  var heap = binaryHeap(function(a, b){
        return a.area < b.area;
      }),
      last = 0,
      check;

  points.forEach(function(point, i){

    point[2] = i;
    point.prev = points[i - 1];
    point.next = points[i + 1];
    point.area = getArea(point.prev, point, point.next);

    heap.insert(point);

  });

  while (check = heap.pop()) {

    check.area = last = Math.max(check.area, last);

    if (check.prev) {
      check.prev.next = check.next;
      recalc(check.prev);
    }

    if (check.next) {
      check.next.prev = check.prev;
      recalc(check.next);
    }

  }

  return points.filter(function(p){
    return p.area > threshold;
  });

  function recalc(point) {
    point.area = getArea(point.prev, point, point.next);
    heap.update(point);
  }

  function getArea(a,b,c) {

    if (!a || !c) {
      return Infinity;
    }

    return Math.abs(a[0] * b[1] - a[0] * c[1] + b[0] * c[1] - b[0] * a[1] + c[0] * a[1] - c[0] * b[1]) / 2;

  }

}

function binaryHeap(comparator) {

  var heap = {},
      nodes = [];

  heap.remove = function(val) {

    var len = nodes.length,
        end;

    for (var i = 0; i < len; i++) {

      if (nodes[i] === val) {

        end = nodes.pop();

        if (i < len - 1) {
          nodes[i] = end;
          this.sink(i);
        }

        break;

      }

    }

    return this;

  };

  heap.pop = function() {

    var top = nodes.shift();

    if (nodes.length) {
      nodes.unshift(nodes.pop());
      this.sink(0);
    }

    return top;

  };

  heap.bubble = function(i) {

    var pi = Math.floor((i + 1) / 2) - 1;

    if (i > 0 && this.compare(i, pi)) {
      this.swap(i, pi);
      this.bubble(pi);
    }

    return this;

  };

  heap.sink = function(i) {

    var len = nodes.length,
        ci = 2 * i + 1;

    if (ci < len - 1 && this.compare(ci + 1, ci)) {
      ci++;
    }

    if (ci < len && this.compare(ci, i)) {

      this.swap(i, ci);
      this.sink(ci);

    }

    return this;

  };

  heap.compare = function(i, j) {
    return comparator(nodes[i], nodes[j]);
  };

  heap.insert = function(d) {
    this.bubble(nodes.push(d) - 1);
  };

  heap.size = function() {
    return nodes.length;
  }

  heap.swap = function(i, j) {
    var swap = nodes[i];
    nodes[i] = nodes[j];
    nodes[j] = swap;
  };

  heap.update = function(d) {
    this.remove(d);
    this.insert(d);
    // bubble / sink instead?
  }

  heap.nodes = nodes;

  return heap;

}

warper.js

function warper(start,end) {

  var u0 = start[0][0],
      v0 = start[0][1],
      u1 = start[1][0],
      v1 = start[1][1],
      u2 = start[2][0],
      v2 = start[2][1],
      u3 = start[3][0],
      v3 = start[3][1],
      x0 = end[0][0],
      y0 = end[0][1],
      x1 = end[1][0],
      y1 = end[1][1],
      x2 = end[2][0],
      y2 = end[2][1],
      x3 = end[3][0],
      y3 = end[3][1];

  var square = [
    [1,u0,v0,u0 * v0,0,0,0,0],
    [1,u1,v1,u1 * v1,0,0,0,0],
    [1,u2,v2,u2 * v2,0,0,0,0],
    [1,u3,v3,u3 * v3,0,0,0,0],
    [0,0,0,0,1,u0,v0,u0 * v0],
    [0,0,0,0,1,u1,v1,u1 * v1],
    [0,0,0,0,1,u2,v2,u2 * v2],
    [0,0,0,0,1,u3,v3,u3 * v3]
  ];

  // Prevent float precision problems in FF/Safari
  square.forEach(function(row){
    row.forEach(function(cell, i){
      row[i] = cell.toFixed(6);
    });
  });

  var inverted = invert(square);

  var s = multiply(inverted,[x0,x1,x2,x3,y0,y1,y2,y3]);

  return function(p) {

    return [
      s[0] + s[1] * p[0] + s[2] * p[1] + s[3] * p[0] * p[1],
      s[4] + s[5] * p[0] + s[6] * p[1] + s[7] * p[0] * p[1],
    ];

  };

}

function multiply(matrix,vector) {

  return matrix.map(function(row){

    var sum = 0;

    row.forEach(function(c,i){
      sum += c * vector[i];
    });

    return sum;

  });

}

function invert(matrix) {

  var size = matrix.length,
      base,
      swap,
      augmented;

  // Augment w/ identity matrix
  augmented = matrix.map(function(row,i){
    return row.slice(0).concat(row.slice(0).map(function(d,j){
      return j === i ? 1 : 0;
    }));
  });

  // Process each row
  for (var r = 0; r < size; r++) {

    base = augmented[r][r];

    // Zero on diagonal, swap with a lower row
    if (!base) {

      for (var rr = r + 1; rr < size; rr++) {

        if (augmented[rr][r]) {
          // swap
          swap = augmented[rr];
          augmented[rr] = augmented[r];
          augmented[r] = swap;
          base = augmented[r][r];
          break;
        }

      }

      if (!base) {
        throw new Error("Not invertable :(");
      }

    }

    // 1 on the diagonal
    for (var c = 0; c < size * 2; c++) {

      augmented[r][c] = augmented[r][c] / base;

    }

    // Zeroes elsewhere
    for (var q = 0; q < size; q++) {

      if (q !== r) {

        base = augmented[q][r];

        for (var p = 0; p < size * 2; p++) {
            augmented[q][p] -= base * augmented[r][p];
        }

      }

    }

  }

  return augmented.map(function(row){
    return row.slice(size);
  });

}