Published
Edited
Dec 16, 2021
3 stars
Insert cell
Insert cell
starters = ["Cheng Pei-Pei", "Michelle Yeoh", "Zhang Ziyi"]
Insert cell
viewof selected = Inputs.table(actresses, {
required: false,
value: _.map(starters, name => _.find(actresses, d => d.name === name)),
columns: ['id', 'name', 'birthday', 'place_of_birth', 'cast', 'crew', 'action', 'existing'],
format: {
cast: d => d.length,
crew: d => d.length,
action: d => d.length,
existing: d => d.length,
movies: d => d.length,
}
})
Insert cell
Inputs.table(selected[0].cast, {
sort: 'release_date',
columns: ['title', 'original_title', 'release_date', 'character', 'genres']
})
Insert cell
Inputs.table(selected[1].cast, {
sort: 'release_date',
columns: ['title', 'original_title', 'release_date', 'character', 'genres']
})
Insert cell
Inputs.table(selected[2].cast, {
sort: 'release_date',
columns: ['title', 'original_title', 'release_date', 'character', 'genres']
})
Insert cell
md`
## # acrylic plates

- top:
- decades
- actress birth year
- actress names
- actress co-occurrences: title & year
- middle:
- decades
- actress life span
- bottom:
- decades

*All measurements in inches.
`
Insert cell
minYear = new Date('01-01-1940')
Insert cell
maxYear = new Date('12-31-2019')
Insert cell
rectWidth = 0.125 * dpi
Insert cell
rectHeight = 0.3 * dpi
Insert cell
startAngle = 40
Insert cell
endAngle = 360
Insert cell
angleScale = d3.scaleLinear([minYear, maxYear], [startAngle, endAngle])
Insert cell
innerRadius = 2
Insert cell
outerRadius = 5.9
Insert cell
dpi = 72
Insert cell
actressRadii = [3, 4, 5]
Insert cell
Insert cell
{
// generate
const {svg, container} = generateBaseSVG()

const padding = (1/32) * dpi
const arc = d3.arc().cornerRadius(padding);
// draw slits for each actress's lives
const actress = container.append('g')
.attr('id', 'actresses')
.selectAll('g')
.data(selected)
.join('g')
.attr('id', 'actress')

actress.selectAll('path')
.data(({birthday}, i) => {
const startAngle = angleScale(new Date(birthday))
const radius = actressRadii[i] * dpi

return _.map([0.125 * dpi, -0.125 * dpi], offset => {
return arc({
startAngle: (startAngle / 180) * Math.PI,
endAngle: (endAngle / 180) * Math.PI,
innerRadius: radius + offset - padding,
outerRadius: radius + offset + padding,
})
})
}).join('path')
.attr('d', d => d)
.attr('fill', 'none')
.attr('stroke', '#000')
.attr('stroke-width', '0.1pt')

const instruction = _.map([0.125 * dpi, -0.125 * dpi], offset => {
const radius = actressRadii[2] * dpi
return arc({
startAngle: (5 / 180) * Math.PI,
endAngle: ((startAngle - 5) / 180) * Math.PI,
innerRadius: radius + offset - padding,
outerRadius: radius + offset + padding,
})
})
container.append('g')
.attr('id', 'instruction')
.selectAll('path')
.data(instruction)
.join('path')
.attr('d', d => d)
.attr('fill', 'none')
.attr('stroke', '#000')
.attr('stroke-width', '0.1pt')
return svg.node()
}
Insert cell
{
// generate
const {svg, container} = generateBaseSVG()

const padding = (1/32) * dpi
const arc = d3.arc().cornerRadius(padding);

container.selectAll('#decades g')
.append('text')
.attr('transform', `translate(0, ${-(outerRadius) * dpi + rectHeight})`)
.attr('dy', '.35em')
.text(d => d3.timeFormat('%Y')(d))
console.log(container.selectAll('#decades g'))
// draw slits for each actress's lives
const actress = container.append('g')
.attr('id', 'actresses')
.selectAll('g')
.data(selected)
.join('g')
.attr('id', 'actress')

actress.append('path')
.attr('d', ({birthday}, i) => {
const radius = actressRadii[i] * dpi
return arc({
startAngle: (startAngle / 180) * Math.PI,
endAngle: (endAngle / 180) * Math.PI,
innerRadius: radius,
outerRadius: radius,
})
})
.attr('fill', 'none')
.attr('stroke', '#000')
return svg.node()
}
Insert cell
{
// generate
const {svg, container} = generateBaseSVG()
const fontSize = 24

const data = _.chain(selected)
.map(({cast, name}, index) => {
return _.map(cast, (d) => {
console.log(index)
return {...d, name, index}
})
}).flatten()
.filter(({genres}) => !_.includes(genres, 'Documentary'))
.groupBy('id')
.filter(roles => roles.length > 1)
.map((roles) => {
const {title, release_date} = roles[0]
const date = new Date(release_date)
const angle = angleScale(date)

return {
angle,
title,
year: d3.timeFormat('%Y')(date),
roles: _.map(roles, ({index, character}) => {
return {
radius: actressRadii[index] * dpi,
character,
}
})
}
})
.value()
console.log(data)

const costar = container.append('g')
.attr('id', 'costars')
.selectAll('g')
.data(data).join('g')
.attr('transform', ({angle}) => `rotate(${angle})`)

costar.append('text')
.attr('transform', `translate(0, ${-(actressRadii[2]) * dpi - fontSize})`)
.text(d => `${d.title} ${d.year}`)

costar.selectAll('g')
.data(d => d.roles)
.join('g')
.attr('transform', d => `translate(0, ${-d.radius})`)
.selectAll('circle')
.data(d => [-0.125 * dpi, 0.125 * dpi])
.join('circle')
.attr('cx', d => d)
.attr('r', (1/16) * dpi)
.attr('fill', 'none')
.attr('stroke', '#000')
.attr('stroke-width', '0.1pt')
return svg.node()
}
Insert cell
viewof actress = Inputs.select(selected, {format: d => d.name})
Insert cell
actress
Insert cell
margin = new Object({top: 0, right: 0, bottom: 0, left: 0})
Insert cell
embroiderySize = {
const index = _.indexOf(selected, actress)

const radius = actressRadii[index]
return 2 * Math.PI * radius * ((endAngle - startAngle) / 360) // in inches
}
Insert cell
{
let {cast, action} = actress
cast = _.filter(cast, d => d3.timeDay.count(new Date(d.release_date), maxYear) > 0)
action = _.filter(action, d => d3.timeDay.count(new Date(d.release_date), maxYear) > 0)
const birthday = new Date(actress.birthday)
const startAngle = angleScale(birthday)
const width = embroiderySize * dpi
const height = 2.5 * dpi

const svg = d3.select(DOM.svg(width, height))
const minMonths = 3
const maxMonths = 30
const xScale = d3.scaleLinear(
[birthday, maxYear],
[margin.left, width - margin.right],
)
const yScale = d3.scaleSqrt(
// [0, 1], // 0 = top bill order, 1 = very bottom
d3.extent(cast, d => d.total - d.order),
[height - 2 * margin.bottom, margin.top],
)
const lineGen = d3.line()
.x(d => d.x).y(d => d.y)
.curve(d3.curveBasis)

function calcPaths(birthday, cast, index) {
return _.chain(cast)
.filter(d => d.release_date)
.sortBy(d => new Date(d.release_date))
.map(d => {
const date = new Date(d.release_date)
const mx = xScale(date)
const my = yScale(d.total - d.order)
const offset = maxMonths
const x1 = xScale(d3.timeMonth.offset(date, -offset))
const x2 = xScale(d3.timeMonth.offset(date, offset))
const y1 = height - margin.bottom
const dx = (mx - x1) / 10
const dy = (my - y1) / 2
console.log(mx, dx, my, dy)
return {
path: `
M${x1},${y1}
C${x1 + dx},${y1} ${mx - dx},${my} ${mx},${my}
C${mx + dx},${my} ${x2 - dx},${y1} ${x2},${y1}
`,
color: index ? '#68e8bb' : '#54a7fa',
title: d.title,
}
})
.value()
}

const others = _.differenceBy(cast, action, d => d.id)
const paths = _.chain([action, others])
.map((cast, i) => calcPaths(birthday, cast, i))
// .flatten()
.value()
const dots = _.chain(cast)
.sortBy(d => new Date(d.release_date))
.map(({title, release_date, total, order, genres}) => {
const isAction = _.includes(genres, 'Action')
return {
title: title,
x: xScale(new Date(release_date)),
y: yScale(total - order),
color: !isAction ? '#68e8bb' : '#54a7fa',
lineWidth: !isAction ? 1 : 2,
}
}).groupBy('lineWidth').values().value()
console.log(dots)

// start drawing
// actress
const mountain = svg.append('g').attr('id', 'mountains')
.selectAll('g').data(paths).join('g')
mountain.selectAll('path')
.data(d => d).join('path')
.attr('d', d => d.path)
.attr('id', d => d.title)
.attr('fill', 'none')
.attr('stroke', d => d.color)
// .attr('opacity', 0.5)
// .attr('stroke-width', 2)

const movies = svg.append('g').attr('id', 'movies')
.selectAll('g').data(dots).join('g')
.attr('transform', (d, i) => `translate(0, ${(i + 1) * 5})`)
movies.selectAll('circle')
.data(d => d).join('circle')
.attr('id', d => d.title)
.attr('transform', d => `translate(${d.x}, 0)`)
.attr('fill', 'none')
.attr('stroke', d => d.color)
.attr('stroke-width', d => d.lineWidth)
.attr('r', 3)


svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', 'none')
.attr('stroke', '#000')
return scrollSVG(svg.node())
}
Insert cell
{
let {cast, action} = actress
cast = _.filter(cast, d => d3.timeDay.count(new Date(d.release_date), maxYear) > 0)
action = _.filter(action, d => d3.timeDay.count(new Date(d.release_date), maxYear) > 0)
const birthday = new Date(actress.birthday)
const width = embroiderySize * dpi
const height = 3.75 * dpi

const svg = d3.select(DOM.svg(width, height))
const minMonths = 12
const maxMonths = 12
const xScale = d3.scaleLinear(
[birthday, maxYear],
[margin.left, width - margin.right],
)
const yScale = d3.scaleSqrt(
// [0, 1], // 0 = top bill order, 1 = very bottom
d3.extent(cast, d => d.total - d.order),
[height, margin.top],
)
const lineGen = d3.line()
.x(d => d.x).y(d => d.y)
.curve(d3.curveBasis)

function calcPoints(cast) {
cast = _.sortBy(cast, d => d.release_date)
let prevDate = d3.timeMonth.offset(cast[0].release_date, -minMonths)
let prevX = xScale(prevDate)
let prevY = height

const points = [
{
x: prevX,
y: prevY,
}
]
_.each(cast, (d, i) => {
const midpoint = {x: prevX, y: prevY}
const current = {
x: xScale(d.release_date),
y: yScale(d.total - d.order),
}
if (i) {
// if not first point then calculate actual midpoint between previous point and this one
midpoint.x = (prevX + current.x) / 2
midpoint.y = Math.max(prevY, current.y) + (current.x - prevX) / 2
midpoint.y = Math.min(midpoint.y, height)
}

const moreThanMax = d3.timeMonth.count(prevDate, d.release_date) > 2 * maxMonths
if (i && moreThanMax) {
points.push({
x: xScale(d3.timeMonth.offset(prevDate, minMonths)),
y: midpoint.y,
})
}
points.push(midpoint)
if (i && moreThanMax) {
points.push({
x: xScale(d3.timeMonth.offset(d.release_date, -minMonths)),
y: midpoint.y,
})
}
points.push(current)

prevX = current.x
prevY = current.y
prevDate = d.release_date
})
points.push({
x: xScale(d3.timeMonth.offset(_.last(cast).release_date, minMonths)),
y: height,
})
points.push({x: width - margin.right, y: height})

return lineGen(points)
}

function calcPaths(cast, index) {
cast = _.chain(cast)
.filter(d => d.release_date)
.map(d => {
return {...d, release_date: new Date(d.release_date)}
})
.sortBy('release_date')
.value()
const paths = []
while (cast.length) {
const path = []
let prevDate
_.each(cast, (d, i) => {
// if less than 2 years since last movie, return
if (prevDate && d3.timeMonth.count(prevDate, d.release_date) < minMonths) return
// else add point to path and update prevDate
path.push(d)
prevDate = d.release_date
})

// then subtract path points from path, and add path to paths
cast = _.difference(cast, path)
paths.push(path)
}

// then calculate paths
return _.map(paths, path => {
return {path: calcPoints(path), color: index ? '#68e8bb' : '#54a7fa'} // blue is action green is other
})
}
const others = _.differenceBy(cast, action, d => d.id)
const data = _.chain([action, others])
.map((cast, i) => calcPaths(cast, i))
.value()
console.log(data)

const mountain = svg.append('g').attr('id', 'mountains')
.selectAll('g').data(data).join('g')
mountain.selectAll('path')
.data(d => d).join('path')
.attr('d', d => d.path)
.attr('fill', 'none')
.attr('stroke', d => d.color)
.attr('stroke-width', 2)

svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', 'none')
.attr('stroke', '#000')
return scrollSVG(svg.node())
}
Insert cell
selected
Insert cell
Insert cell
actresses = FileAttachment("actresses.json").json()
.then(data => {
return _.map(data, d => Object.assign(d, {
cast: _.map(d.cast, c => {
const movie = _.find(movies, ({id}) => c.id === id)
return Object.assign(c, {total: movie && movie.cast})
}),
action: _.map(d.action, c => {
const movie = _.find(movies, ({id}) => c.id === id)
return Object.assign(c, {total: movie && movie.cast})
}),
}))
})
Insert cell
movies = FileAttachment("movies.json").json()
Insert cell
sheetID = ""
Insert cell
tabID = ""
Insert cell
// rawData = d3.csv(`https://docs.google.com/spreadsheets/d/${sheetID}/gviz/tq?tqx=out:csv&sheet=${tabID}`)
Insert cell
d3 = require('d3')
Insert cell
_ = require('lodash')
Insert cell
import { vl } from "@vega/vega-lite-api"
Insert cell
import {scrollSVG} from '@sxywu/utility-functions'
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