Public
Edited
Apr 8
Insert cell
Insert cell
import { aq, op } from '@uwdata/arquero'
Insert cell
embed = require("vega-embed@5")
Insert cell
Insert cell
viewof tournages = aq // viewof shows the table view, but assigns the table value
.fromCSV(await FileAttachment('tournages@3.csv').text())
.view({ height: 240 })
Insert cell
Insert cell
Insert cell
arrondissementsParis = FileAttachment("arrondissements.geojson").json()
Insert cell
Insert cell
geoArrondissementsSorted = {
return {
type: "FeatureCollection",
features: arrondissementsParis.features
.map(f => ({
...f,
properties: {
...f.properties,
c_ar: +f.properties.c_ar // conversion explicite en nombre
}
}))
.sort((a, b) => a.properties.c_ar - b.properties.c_ar) // tri croissant
};
}
Insert cell
centroïdes = geoArrondissementsSorted.features.map(f => {
const [lon, lat] = d3.geoCentroid(f);
return {
arrondissement: +f.properties.c_ar,
lon,
lat
};
})
Insert cell
tournages_par_arrondissement = {
// Comptage
const counts = d3.rollups(
tournages,
v => v.length,
d => +d.arrondissement
);

// On fusionne avec les centroïdes
return counts.map(([arr, count]) => {
const centroid = centroïdes.find(c => c.arrondissement === arr);
return {
arrondissement: arr,
count,
lon: centroid?.lon ?? null,
lat: centroid?.lat ?? null
};
}).filter(d => d.lon != null);
}
Insert cell
Insert cell
embed({
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
width: 700,
height: 600,
projection: {type: "mercator"},
layer: [

{
data: {
values: arrondissementsParis.features
},
mark: {
type: "geoshape",
fill: "#eee",
stroke: "white"
},
encoding: {
tooltip: {
field: "properties.l_ar",
type: "nominal",
title: "Arrondissement"
}
}
},
{
data: {
values: tournages_par_arrondissement
},
mark: {
type: "circle",
color: "#377eb8",
opacity: 0.8,
stroke: "white"
},
encoding: {
longitude: {field: "lon", type: "quantitative"},
latitude: {field: "lat", type: "quantitative"},
size: {
field: "count",
type: "quantitative",
scale: {type: "sqrt", range: [20, 1500]},
legend: {title: "Nombre de tournages"}
},
tooltip: [
{field: "arrondissement", title: "Arrondissement"},
{field: "count", title: "Tournages"}
]
}
}
]
})
Insert cell
Insert cell
parts_par_arrondissement = tournages
.groupby("arrondissement")
.rollup({ count: d => op.count() })
.derive({
percent: d => d.count / op.sum(d.count)
})
.orderby("arrondissement")
Insert cell
parts_par_arrondissement_annee = (
tournages
.filter(d => d["arrondissement"] != null && d["Année du tournage"] != null)
.groupby("Année du tournage", "arrondissement")
.count()
.rename({count: "nb"})
.groupby("Année du tournage")
.orderby("Année du tournage","arrondissement")
.derive({
total_annee: d => aq.op.sum(d.nb)
})
.derive({
percent: d => d.nb / d.total_annee
})
.select("Année du tournage", "arrondissement", "nb", "percent")
.rename({"Année du tournage": "year"})
)
Insert cell
Cette visualisation prolonge le récit en ajoutant une dimension essentielle à la lecture de la distribution des tournages : le temps. Elle permet d’observer l’évolution annuelle de la part des tournages dans chaque arrondissement entre 2016 et 2020. Pour cela, nous avons opté pour une série de cartes choroplèthes animées, où la couleur traduit la part relative des tournages à l’échelle parisienne pour chaque année.

Le choix de la choroplèthe repose ici sur une volonté de représenter des données relatives. Chaque couleur, issue d’un dégradé de vert, exprime la proportion de tournages d’un arrondissement par rapport à l’ensemble des tournages parisiens pour une année donnée. L’intensité de la couleur permet ainsi de comparer immédiatement les contributions relatives des arrondissements entre eux.

L’interaction repose sur le survol de chaque zone, qui affiche une infobulle précisant l’arrondissement, la part de tournages et le nombre pour l’année en cours. L’utilisateur est ainsi guidé dans une exploration spatio-temporelle qui fait émerger des tendances : certains arrondissements semblent stables dans leur attractivité, tandis que d’autres connaissent des variations notables. Il peut également remarquer que les arrondissements périphériques concentrent plus de tournages.
Insert cell
viewof carte_animée = {
const container = html`<div style="position: relative; width: 700px; height: 600px;"></div>`;

const allData = parts_par_arrondissement_annee.objects();
const years = Array.from(new Set(allData.map(d => d.year))).sort();

const cards = await Promise.all(
years.map(async year => {
const yearData = allData
.filter(d => d.year === year)
.map(d => ({
arrondissement: d.arrondissement,
percent: d.percent,
count: d.nb
}));

const vis = await embed({
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
width: 700,
height: 600,
title: `Tournages à Paris en ${year}`,
projection: {type: "mercator"},
layer: [
{
data: {
url: "https://mjlobo.github.io/teaching/eivp/arrondissementswithid.json",
format: {
type: "topojson",
feature: "arrondissements"
}
},
transform: [
{
lookup: "properties.c_ar",
from: {
data: {values: yearData},
key: "arrondissement",
fields: ["percent", "count"]
}
}
],
mark: {
type: "geoshape",
stroke: "white"
},
encoding: {
color: {
field: "percent",
type: "quantitative",
title: "Part des tournages",
scale: {scheme: "greens"},
legend: {
title: "Part des tournages",
format: ".1%",
orient: "right"
}
},
tooltip: [
{field: "properties.l_ar", title: "Arrondissement"},
{field: "percent", type: "quantitative", format: ".1%", title: "Part des tournages"},
{field: "count", type: "quantitative", title: "Nombre de tournages"}
]
}
}
]
});

vis.style.position = "absolute";
vis.style.top = 0;
vis.style.left = 0;
vis.style.transition = "opacity 0.5s";
vis.style.opacity = 0;

return vis;
})
);

cards.forEach(c => container.appendChild(c));

let current = 0;
function update() {
cards.forEach((el, i) => {
el.style.opacity = i === current ? 1 : 0;
});
current = (current + 1) % cards.length;
}

update();
const interval = setInterval(update, 2000);

container.addEventListener("disconnect", () => clearInterval(interval));

return container;
}
Insert cell
Insert cell
viewof carte_tournages_annees = {
const years = Array.from(new Set(tournages.objects().map(d => d["Année du tournage"]))).sort();
const yearMin = Math.min(...years);
const yearMax = Math.max(...years);

const slider = Inputs.range([yearMin, yearMax], {
step: 1,
label: "Année de tournage",
value: yearMin
});

const playBtn = html`<button style="margin-left: 1em;">Play</button>`;
let playing = false;
let interval;

const chartDiv = html`<div style="width: 700px; height: 600px;"></div>`;

async function draw(year) {
const data = tournages.objects().filter(
d => d["Année du tournage"] === year && d.lon && d.lat
);

const spec = {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
width: 700,
height: 600,
projection: {type: "mercator"},
encoding: {
longitude: {field: "lon", type: "quantitative", scale: {zero: false}},
latitude: {field: "lat", type: "quantitative", scale: {zero: false}}
},
layer: [
{
data: {
url: "https://mjlobo.github.io/teaching/eivp/arrondissementswithid.json",
format: {type: "topojson", feature: "arrondissements"}
},
mark: {type: "geoshape", fill: "#f5f5f5", stroke: "white"}
},
{
data: {values: data},
mark: {type: "circle", color: "firebrick", opacity: 0.6},
encoding: {
longitude: {field: "lon", type: "quantitative"},
latitude: {field: "lat", type: "quantitative"},
tooltip: [
{field: "Titre", title: "Titre"},
{field: "Type de tournage", title: "Type"},
{field: "Réalisateur", title: "Réalisateur"},
{field: "Année du tournage", title: "Année"}
]
}
}
],
selection: {
zoom: {
type: "interval",
bind: "scales",
encodings: ["x", "y"]
}
}
};

chartDiv.innerHTML = "";
await embed(chartDiv, spec);
}

slider.addEventListener("input", () => {
draw(slider.value);
});

playBtn.onclick = () => {
playing = !playing;
playBtn.textContent = playing ? "Pause" : "Play";

if (playing) {
interval = setInterval(() => {
let next = slider.value + 1;
if (next > yearMax) next = yearMin;
slider.value = next;
slider.dispatchEvent(new Event("input"));
}, 2000);
} else {
clearInterval(interval);
}
};

slider.dispatchEvent(new Event("input"));

return html`<div>
<div style="margin-bottom: 10px;">${slider}${playBtn}</div>
${chartDiv}
</div>`;
}
Insert cell
Insert cell
tournages_emily = tournages.objects().filter(d => d.Titre && d.Titre.toLowerCase().includes("emily"))
Insert cell
import {Inputs} from "@observablehq/inputs"
Insert cell
Insert cell
viewof carte_etape_emily = {
const data = tournages_emily ?? [];
let playing = false;
let interval = null;

const slider = Inputs.range([0, data.length - 1], {
step: 1,
label: "Étape",
value: 0
});

const playButton = html`<button style="margin-left: 10px;">Play</button>`;
const dateDisplay = html`<p style="margin: 0; font-weight: bold;"></p>`;

const chartDiv = html`<div style="width: 700px; height: 600px;"></div>`;

const container = html`<div style="display: flex; flex-direction: column; gap: 10px;"></div>`;
const topBar = html`<div style="display: flex; align-items: center;">`;
topBar.append(slider, playButton);
container.append(topBar, dateDisplay, chartDiv);

function updateCarte(step) {
const lieu = data[step];
if (!lieu) return;

const date = lieu["Date de début"];
const dateStr = date instanceof Date
? date.toLocaleDateString()
: new Date(date).toLocaleDateString();

dateDisplay.textContent = `Date : ${dateStr}`;

embed(chartDiv, {
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
width: 700,
height: 600,
projection: {type: "mercator"},
layer: [
{
data: {
values: arrondissementsParis.features
},
mark: {
type: "geoshape",
fill: "#eee",
stroke: "white"
},
encoding: {
tooltip: {
field: "properties.l_ar",
type: "nominal",
title: "Arrondissement"
}
}
},
{
data: {values: [lieu]},
mark: {type: "circle", color: "#d73027", size: 200},
encoding: {
longitude: {field: "lon", type: "quantitative"},
latitude: {field: "lat", type: "quantitative"},
tooltip: [
{field: "Localisation de la scène", title: "Lieu"},
{field: "Date de début", type: "temporal", title: "Date"},
{field: "Type de tournage", title: "Type"}
]
}
}
]
});
}

playButton.onclick = () => {
playing = !playing;
playButton.textContent = playing ? "Pause" : "Play";

if (playing) {
interval = setInterval(() => {
slider.value = (slider.value + 1) % data.length;
slider.dispatchEvent(new Event("input"));
}, 380);
} else {
clearInterval(interval);
}
};

slider.addEventListener("input", () => updateCarte(slider.value));
updateCarte(slider.value);

return container;
}
Insert cell
parts_emily_arr = aq.from(tournages_emily)
.filter(d => d.arrondissement != null)
.groupby("arrondissement")
.count()
.rename({ count: "nb_emily" })
.derive({
part: aq.escape(d => d.nb_emily / tournages_emily.length)
})
.orderby("arrondissement")
Insert cell
Insert cell
embed({
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
width: 700,
height: 600,
projection: { type: "mercator" },
layer: [
{
data: {
url: "https://mjlobo.github.io/teaching/eivp/arrondissementswithid.json",
format: { type: "topojson", feature: "arrondissements" }
},
transform: [
{
lookup: "properties.c_ar",
from: {
data: { values: parts_emily_arr.objects() },
key: "arrondissement",
fields: ["part"]
}
}
],
mark: {
type: "geoshape",
stroke: "#ccc",
strokeWidth: 0.5
},
encoding: {
color: {
field: "part",
type: "quantitative",
title: "Part des tournages Emily in Paris",
scale: {
scheme: "greens",
domainMin: 0
},
legend: {
format: ".0%",
title: "Part des tournages"
}
},
tooltip: [
{ field: "properties.l_ar", title: "Arrondissement" },
{
field: "part",
type: "quantitative",
format: ".1%",
title: "Part des tournages"
}
]
}
}
],
config: {
mark: {
invalid: null
}
}
})
Insert cell
Insert cell
FileAttachment("kepler.png").image()
Insert cell
mois = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet","Août", "Septembre", "Octobre","Novembre", "Décembre"]
Insert cell
parts_par_type_et_mois = aq.from(tournages)
.filter(d => d["Type de tournage"] != null && d.mois_debut >= 0 && d.mois_debut <= 11)
.groupby(["Type de tournage", "mois_debut"])
.rollup({ nb: op.count() })
.params({ mois })
.derive({ mois_nom: d => mois[d.mois_debut] })
.groupby("mois_nom")
.derive({ percent: d => d.nb / op.sum(d.nb) })
Insert cell
Insert cell
Insert cell
embed({
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
title: "Répartition des types de tournages par mois",
width: 700,
height: 400,
data: {
values: parts_par_type_et_mois.objects()
},
mark: "bar",
encoding: {
x: {
field: "mois_nom",
type: "ordinal",
title: "Mois",
sort: [
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"
]
},
y: {
field: "nb",
type: "quantitative",
title: "Nombre de tournages"
},
color: {
field: "Type de tournage",
type: "nominal",
title: "Type"
},
tooltip: [
{ field: "mois_nom", title: "Mois" },
{ field: "Type de tournage", title: "Type" },
{ field: "nb", title: "Tournages" },
{ field: "percent", title: "Part", format: ".1%" }
]
}
})
Insert cell
parts_par_type_et_arrondissement_normalized = {
const table = aq.from(tournages)
.filter(d => d["Type de tournage"] === "Long métrage" || d["Type de tournage"] === "Série TV")
.filter(d => d.arrondissement != null)
.groupby(["arrondissement", "Type de tournage"])
.count()
.rename({count: "nb"});

const totalParArrondissement = table
.groupby("arrondissement")
.rollup({ total: aq.op.sum("nb") });

return table
.join(totalParArrondissement, "arrondissement")
.derive({
percent: d => d.nb / d.total
})
.orderby("arrondissement", "Type de tournage");
}
Insert cell
Insert cell
embed({
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
title: "Répartition Films / Séries par arrondissement",
width: 700,
height: 400,
data: {
values: parts_par_type_et_arrondissement_normalized.objects()
},
mark: "rect",
encoding: {
x: {
field: "arrondissement",
type: "ordinal",
title: "Arrondissement",
sort: Array.from({length: 20}, (_, i) => (i + 1).toString())
},
y: {
field: "Type de tournage",
type: "nominal",
title: "Type"
},
color: {
field: "percent",
type: "quantitative",
title: "Part locale du type",
scale: { scheme: "magma" }
},
tooltip: [
{ field: "arrondissement", title: "Arrondissement" },
{ field: "Type de tournage", title: "Type" },
{ field: "nb", title: "Tournages" },
{ field: "percent", format: ".1%", title: "Part dans l'arrondissement" }
]
},
config: {
view: { strokeWidth: 0 },
axis: { domain: false }
}
})
Insert cell
Insert cell
coRealisations = {
const liens = new Map();

tournages.objects().forEach(d => {
const raw = d["Réalisateur"];
if (!raw) return;

const noms = raw
.split(/[,/&]+| et /i)
.map(n => n.trim())
.filter(n => n.length > 1);

if (noms.length > 1) {
for (let i = 0; i < noms.length; i++) {
for (let j = i + 1; j < noms.length; j++) {
const [a, b] = [noms[i], noms[j]].sort();
const key = `${a}|${b}`;
liens.set(key, (liens.get(key) || 0) + 1);
}
}
}
});

const links = Array.from(liens.entries()).map(([key, value]) => {
const [source, target] = key.split("|");
return {source, target, value};
});

return links;
}
Insert cell
coRealisateursNodes = {
const noms = new Set();
coRealisations.forEach(l => {
noms.add(l.source);
noms.add(l.target);
});
return Array.from(noms).map(name => ({id: name}));
}
Insert cell
Insert cell
viewof graph_coRealisateurs = {
const {ForceGraph} = require("https://cdn.jsdelivr.net/npm/graphology-layout-forceatlas2@0.7.1/build/forceatlas2.min.js");
const d3 = await require("d3@7");

const nodes = coRealisateursNodes;
const links = coRealisations;

const width = 700;
const height = 600;

const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(60).strength(1))
.force("charge", d3.forceManyBody().strength(-30))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide(12)); // facultatif

const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);

const container = svg.append("g").attr("id", "container");

const link = container.append("g")
.attr("stroke", "#ccc")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(links)
.join("line")
.attr("stroke-width", d => Math.sqrt(d.value));
const node = container.append("g")
.attr("fill", "#d73027")
.selectAll("circle")
.data(nodes)
.join("circle")
.attr("r", 5)
.call(drag(simulation));
const label = container.append("g")
.selectAll("text")
.data(nodes)
.join("text")
.text(d => d.id)
.attr("font-size", 9)
.attr("dx", 6)
.attr("dy", 4);

const zoom = d3.zoom()
.scaleExtent([0.1, 10])
.on("zoom", event => {
svg.select("g#container").attr("transform", event.transform);
});

svg.call(zoom);

simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);

node
.attr("cx", d => d.x)
.attr("cy", d => d.y);

label
.attr("x", d => d.x)
.attr("y", d => d.y);
});

function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}

function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}

function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}

return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}

return svg.node();
}
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more