config = ({
// autosize: "none",
view: {stroke: null},
background: "transparent",
padding: {
left: 0,
top: 8,
right: 0,
bottom: 8,
},
font: "Jost",
fontSize: 15,
title: {
offset: 0,
fontSize: 17,
subtitleFontSize: 15,
},
facet: {
fontSize: 15,
labelFontSize: 15,
titleFontSize: 15,
titleFontWeight: "normal",
},
axis: {
labelFontSize: 15,
titleFontSize: 15,
titleFontWeight: "normal",
},
legend: {
labelFontSize: 15,
titleFontSize: 15,
titleFontWeight: "normal",
},
mark: {
fontSize: 15
},
locale: {
number: {
decimal: ",",
thousands: ".",
grouping: [3],
currency: ["", "€"]
},
time: {
dateTime: "%A %e %B %Y, %X",
date: "%d/%m/%Y",
time: "%H:%M:%S",
periods: ["AM", "PM"],
days: [
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag",
"Sonntag"
],
shortDays: [
"Mo",
"Di",
"Mi",
"Do",
"Fr",
"Sa",
"So"
],
months: [
"Jänner",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember"
],
shortMonths: [
"Jan",
"Feb",
"Mar",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez"
]
}
},
})1925 — 2025
Marie bekommt weniger Pension als Otto
colors2 = ["#be0021", "#384a63"]
function makeWidth(inputWidth) {
const outputWidth =
inputWidth > 1400 ? 800 :
inputWidth > 1100 ? inputWidth * 0.525 :
inputWidth > 768 ? inputWidth * 0.475 :
inputWidth * 0.775
return outputWidth
}
function removeAfterLastComma(str) {
const lastCommaIndex = str.lastIndexOf(",");
if (lastCommaIndex === -1) {
return str; // Return the original string if there's no comma
}
return str.substring(0, lastCommaIndex);
}
germanLocale = d3.formatLocale({
decimal: ",", // Decimal separator
thousands: ".", // Thousands separator
grouping: [3], // Grouping for thousands
currency: ["€", ""], // Currency format (optional)
});
path1 = "M60.837 36.945l.498-5.47c0-7.263-1.399-13.073-6.523-16.893C52.008 8.973 45.759 2.001 31.994 2C18.236 2 11.99 8.973 9.188 14.583c-5.124 3.819-6.523 9.63-6.523 16.893l.498 5.47C2.472 37.629 2 38.689 2 40.246c0 4.176 2.442 4.737 3.444 4.791C5.942 53.354 14.301 62 32.001 62c18.793 0 26.05-9.859 26.553-16.962c.614-.028 1.435-.214 2.138-.877c.869-.818 1.308-2.136 1.308-3.915c0-1.557-.472-2.617-1.163-3.301m-1.17 6.134c-.672.632-1.655.442-1.658.443l-.919-.22v.943c0 6.538-6.682 16.267-25.089 16.267S6.913 50.784 6.913 44.246l-.007-.925l-.906.2a1.894 1.894 0 0 1-.378.033c-1.761 0-2.131-1.799-2.131-3.308c0-2.34 1.249-2.831 2.296-2.831c.105 0 .175.007.187.008l.19.024l.18-.069c2.273-.892 3.791-2.253 4.513-4.044c1.396-3.471-.546-7.668-1.707-10.177c-.295-.638-.601-1.296-.681-1.608c.223-1.659 2.953-18.062 23.532-18.062c20.576.002 23.309 16.4 23.531 18.062c-.081.313-.385.971-.681 1.608c-1.161 2.508-3.105 6.706-1.708 10.177c.721 1.791 2.239 3.152 4.513 4.044l.18.067l.186-.021a1.77 1.77 0 0 1 .191-.009c1.047 0 2.296.491 2.296 2.831c0 1.335-.292 2.316-.842 2.833 M32.001 46.423c-4.848 0-8.777 2.227-8.777 4.737c0 .337.074 1.178.211 1.178h2.961l.585-1.401l.524 1.401H40.53c.158 0 .246-.878.246-1.243c0-2.509-3.928-4.672-8.775-4.672 M32.067 9.329a63.897 63.897 0 0 1 6.987.116c2.333.17 4.659.487 7.043.873c-2.121-1.154-4.453-1.918-6.837-2.381c-2.387-.479-4.833-.622-7.261-.556a40.006 40.006 0 0 0-7.186.946c-2.36.505-4.621 1.272-6.909 1.991c2.429.075 4.804-.285 7.15-.494c2.351-.22 4.682-.435 7.013-.495 M32.055 13.438a95.341 95.341 0 0 1 8.52.114c2.844.17 5.681.485 8.563.876c-2.665-1.173-5.51-1.93-8.396-2.39c-2.888-.475-5.823-.615-8.743-.549a58.13 58.13 0 0 0-8.684.943c-2.864.502-5.647 1.273-8.453 1.995c2.922.076 5.799-.281 8.653-.492c2.857-.221 5.698-.436 8.54-.497 M43.461 28.132a8.366 8.366 0 0 0-7.682 5.036c-2.671-.143-5.183-.017-7.466.23a8.361 8.361 0 0 0-7.771-5.267c-4.618 0-8.366 3.734-8.366 8.345c0 4.608 3.748 8.346 8.366 8.346c4.619 0 8.368-3.737 8.368-8.346c0-.113-.014-.226-.018-.34c1.93-.197 4.022-.3 6.229-.213c-.015.183-.03.365-.03.553c0 4.608 3.748 8.346 8.369 8.346c4.617 0 8.364-3.737 8.364-8.346c.001-4.61-3.746-8.344-8.363-8.344M20.542 42.039c-3.08 0-5.577-2.489-5.577-5.563s2.497-5.564 5.577-5.564s5.578 2.49 5.578 5.564s-2.498 5.563-5.578 5.563m22.917 0c-3.08 0-5.578-2.489-5.578-5.563s2.498-5.564 5.578-5.564s5.578 2.49 5.578 5.564s-2.498 5.563-5.578 5.563"
path2 = "M46.984 11.522c.001-.04.011-.078.011-.118C46.995 6.209 40.276 2 31.991 2s-15.005 4.209-15.005 9.404c0 .039.01.076.01.115C1.445 17.482-1.405 32.489 5.653 39.527l.033 5.951C5.687 53.702 13.949 62 32.411 62c16.437 0 25.865-6.021 25.865-16.522l-.005-5.867c7.162-7.005 4.328-22.102-11.287-28.089m3.295 27.562c0 3.313-2.681 6-5.986 6s-5.985-2.687-5.985-6s2.68-6 5.985-6s5.986 2.687 5.986 6M31.991 5.944c5.712 0 10.666 2.005 13.199 4.95c-3.738-1.192-8.116-1.899-13.199-1.899c-5.542 0-12.083 1.541-13.198 1.896c2.535-2.943 7.488-4.947 13.198-4.947m-9.107 21.778c2.647-1.005 7.367-2.846 9.41-3.798c2.441 1.218 5.35 2.486 8.805 3.798a33.41 33.41 0 0 1 5.441 2.657a8.957 8.957 0 0 0-2.246-.295c-3.692 0-6.862 2.236-8.241 5.431a49.833 49.833 0 0 0-8.011.248c-1.319-3.325-4.553-5.679-8.34-5.679c-.786 0-1.543.111-2.269.301a33.332 33.332 0 0 1 5.451-2.663m-3.182 5.362c3.306 0 5.986 2.687 5.986 6s-2.681 6-5.986 6c-3.304 0-5.984-2.687-5.984-6s2.68-6 5.984-6m12.709 26.949c-17.079 0-24.724-7.311-24.724-14.561l-.035-6.269c.847-1.138 2.302-2.896 4.385-4.784a8.959 8.959 0 0 0-1.311 4.664c0 4.971 4.02 9 8.976 9c4.958 0 8.979-4.029 8.979-9c0-.124-.014-.245-.019-.367a47.348 47.348 0 0 1 6.684-.23c-.013.199-.03.396-.03.598c0 4.971 4.021 9 8.979 9c4.956 0 8.978-4.029 8.978-9a8.958 8.958 0 0 0-1.303-4.65a34.331 34.331 0 0 1 4.301 4.667l.006 6.378c-.002 9.249-8.699 14.554-23.866 14.554 M38.158 49.423c.023.612.241 1.188.567 1.705c-1.051.393-3.666 1.235-6.793 1.235c-3.108 0-5.726-.83-6.799-1.228c.328-.518.549-1.097.572-1.713c-.776.814-1.459 1.479-2.24 2.121c-.767.651-1.582 1.237-2.427 1.979c.576.003 1.125-.149 1.639-.362c.52-.213.997-.513 1.435-.866c.133-.111.26-.233.384-.359c.464 1.573 3.612 3.299 7.437 3.299c3.815 0 6.96-1.729 7.434-3.302c.124.127.253.25.387.362c.438.354.914.653 1.433.866c.516.213 1.063.365 1.641.362c-.846-.741-1.661-1.327-2.428-1.979c-.784-.641-1.465-1.306-2.242-2.12"makeDistribution = function({ inputData }) {
const area = vl
.markArea({
interpolate: interpolate,
fillOpacity: 0.5,
strokeWidth: 1,
clip: true,
})
.encode(
vl.x()
.fieldQ("bin")
.axis({
grid: false,
title: "Bruttopension",
domainColor: "black",
tickColor: "black",
domainWidth: 0.75,
tickWidth: 0.75,
labelFlush: false,
labels: width > 768 ? true : false,
ticks: width > 768 ? true : false,
tickMinStep: 1000,
})
.scale({
domain: [-200, 4700],
nice: false,
}),
vl.y()
.fieldQ("N")
.scale({
domain: [50, 26000],
nice: false,
})
.axis({
domain: false,
grid: false,
title: null,
labels: false,
ticks: false,
values: [0, 5000, 10000, 15000, 20000],
// minExtent: 80,
}),
vl.color()
.fieldN("gesl")
.scale({
range: colors2,
})
.legend(null),
// .legend({
// orient: "top",
// symbolOpacity: 1,
// symbolType: "square",
// symbolStrokeColor: "transparent",
// title: null,
// }),
vl.stroke()
.fieldN("gesl")
.scale({
range: radio == "Otto" ? ["transparent", "black"] : ["black", "transparent"],
})
.legend(null),
)
const areaLowTail = vl
.markArea({
interpolate: interpolate,
fillOpacity: 0.75,
strokeWidth: 1,
clip: true,
})
.transform(
vl.filter("datum.bin <= datum.range"),
vl.filter("datum.gesl == datum.radio"),
)
.encode(
vl.x()
.fieldQ("bin"),
vl.y()
.fieldQ("N"),
vl.color()
.fieldN("gesl")
.scale({
range: colors2,
})
.legend(null),
)
const rule = vl
.markRule({
stroke: "black",
strokeDash: [4, 2],
clip: true,
strokeWidth: 0.75,
})
.transform(
vl.filter("datum.bin == datum.range")
)
.encode(
vl.x()
.fieldQ("bin")
)
const ruleGray = vl
.markRule({
opacity: 0.651,
clip: true,
})
.data(dataRule)
.encode(
vl.x()
.fieldQ("median"),
vl.y()
.fieldQ("y"),
vl.y2()
.fieldQ("y2"),
vl.opacity()
.fieldN("gesl")
.scale({
range: radio == "Otto" ?
[0.651, 1] :
[1, 0.651]
})
.legend(null),
)
const textGray1 = vl
.markText({
opacity: 0.651,
size: 14,
y: 50,
dx: -10,
align: "right",
clip: true,
})
.data(dataRule)
.encode(
vl.x()
.fieldQ("median"),
vl.text()
.fieldQ("median")
.format(",.0f"),
vl.opacity()
.fieldN("gesl")
.scale({
range: radio == "Otto" ?
[0.651, 1] :
[1, 0.651]
})
.legend(null),
)
const textGray2 = vl
.markText({
// opacity: 0.651,
size: 14,
y: 66,
dx: -10,
align: "right",
clip: true,
})
.data(dataRule)
.encode(
vl.x()
.fieldQ("median"),
vl.text()
.value("Median"),
vl.opacity()
.fieldN("gesl")
.scale({
range: radio == "Otto" ?
[0.651, 1] :
[1, 0.651]
})
.legend(null),
)
const textGray3 = vl
.markText({
opacity: 0.651,
size: 14,
y: 82,
dx: -10,
align: "right",
clip: true,
})
.data(dataRule)
.encode(
vl.x()
.fieldQ("median"),
vl.text()
.fieldN("gesl"),
vl.opacity()
.fieldN("gesl")
.scale({
range: radio == "Otto" ?
[0.651, 1] :
[1, 0.651]
})
.legend(null),
)
const textLeft = vl
.markText({
clip: true,
size: 15,
y: 10,
dx: -12,
align: "right",
})
.transform(
vl.filter("datum.gesl == datum.radio"),
vl.filter("datum.bin == datum.range"),
)
.encode(
vl.x()
.fieldQ("bin"),
vl.text()
.value("← " + getCumPerc(radio, "lower")),
)
const textLeftOther = vl
.markText({
opacity: 0.651,
clip: true,
size: 14,
y: 27,
dx: -12,
align: "right",
})
.transform(
vl.filter("datum.gesl != datum.radio"),
vl.filter("datum.bin == datum.range"),
)
.encode(
vl.x()
.fieldQ("bin"),
vl.text()
.value(getCumPerc(notRadio, "lower")),
)
const textRight = vl
.markText({
clip: true,
size: 15,
y: 10,
dx: 12,
align: "left",
})
.transform(
vl.filter("datum.gesl == datum.radio"),
vl.filter("datum.bin == datum.range"),
)
.encode(
vl.x()
.fieldQ("bin"),
vl.text()
.value(getCumPerc(radio, "higher") + " →"),
)
const textRightOther = vl
.markText({
opacity: 0.651,
clip: true,
size: 14,
y: 27,
dx: 12,
align: "left",
})
.transform(
vl.filter("datum.gesl != datum.radio"),
vl.filter("datum.bin == datum.range"),
)
.encode(
vl.x()
.fieldQ("bin"),
vl.text()
.value(getCumPerc(notRadio, "higher")),
)
const textMen = vl
.markText({
opacity: radio == "Otto" ? 1 : 0.651,
clip: true,
size: width > 786 ? 14 : 14,
y: width > 786 ? 164 : 120,
align: "center",
})
.data([
{bin: 3500}
])
.encode(
vl.x()
.fieldQ("bin"),
vl.text()
.value(width > 786 ? "⤺ Pensionen der Ottos" : ["⤺ Pensionen der", "Ottos"]),
)
const textWomen = vl
.markText({
opacity: radio == "Marie" ? 1 : 0.651,
clip: true,
size: width > 786 ? 14 : 14,
y: width > 786 ? 90 : 45,
align: "center",
})
.data([
{bin: width > 786 ? 400 : 600}
])
.encode(
vl.x()
.fieldQ("bin"),
vl.text()
.value(["Pensionen", "der Maries ↷ "]),
)
const layers = width > 768 ?
vl.layer(
ruleGray,
textGray1,
textGray2,
textGray3,
area,
areaLowTail,
rule,
textLeft,
textRight,
textLeftOther,
textRightOther,
textMen,
textWomen,
):
vl.layer(
area,
areaLowTail,
rule,
textLeft,
textRight,
textMen,
textWomen,
)
return layers
.height(width > 768 ? 230 : 200)
.width(makeWidth(width))
.data(inputData)
.config(config)
}makeIsotype = function({ inputData, rangeValue }) {
const chartIsotypeMain = vl.markPoint({
size: width > 768 ? 0.5 : 0.25,
stroke: "black",
strokeWidth: 0,
strokeOpacity: 1,
opacity: 0.9,
dy: -100,
clip: true,
})
.transform(
vl.calculate("sequence(0, datum.percRound)").as("positions"),
vl.flatten(["positions"]),
vl.calculate("datum.positions % 10").as("positionX"),
vl.calculate("floor(datum.positions / 10)").as("positionY"),
)
.encode(
vl.y().fieldN("gesl").axis(null),
vl.yOffset()
.fieldQ("positionY")
.scale({
domain: [-0.5, 5.5],
}),
vl.x()
.fieldQ("positionX")
.axis({
title: null,
labels: false,
ticks: false,
domain: false,
grid: false,
})
.scale({
domain: [-0.5, 10.5],
}),
vl.fill()
.fieldN("gesl")
.scale({
range: colors2,
})
.legend(false),
vl.shape()
.fieldN("gesl")
.scale({
range: [
path2,
path1,
]
})
.legend(false)
)
const chartIsotypeText = vl.markText({
size: 15,
dx: 15,
dy: 14,
align: "center",
opacity: 0.651,
clip: true,
})
.transform(
vl.calculate("['↜ ' + datum.text]").as("label"), // , datum.gesl
vl.calculate("datum.percRound % 10").as("positionX"),
vl.calculate("floor(datum.percRound / 10)").as("positionY"),
)
.encode(
vl.y().fieldN("gesl").axis(null),
vl.yOffset()
.fieldQ("positionY")
.scale({
domain: [-0.5, 5.5],
}),
vl.x()
.fieldQ("positionX")
.scale({
domain: [-0.5, 10.5],
}),
vl.text().fieldN("label"),
)
return vl.layer(
chartIsotypeMain,
chartIsotypeText,
)
.width(makeWidth(width) / 2)
.height(340)
.facet({
field: "tail",
type: "nominal",
title: null,
sort: ["lower", "higher"],
scale: {
domain: ["lower", "higher"],
range: ["lower", "higher"],
},
header: {
labelFontSize: 15,
labelOrient: "top",
labelExpr: `datum.value == 'lower' ? 'Pension < ${rangeValue}€' : 'Pension > ${rangeValue}€'`,
labelPadding: -20,
},
})
.resolve({scale: {x: "independent", yOffset: "independent"}})
.data(inputData)
.config(config);
}makeDumbbell = function({ inputData }) {
const labels = vl
.markPoint({
size: 0.5,
opacity: 0.8,
stroke: "black",
strokeWidth: 0,
})
.encode(
vl.x()
.fieldQ("value")
.axis({
title: "Bruttopension",
labels: width > 768 ? true : false,
ticks: width > 768 ? true : false,
domain: true,
grid: false,
domainColor: "black",
tickColor: "black",
domainWidth: 0.75,
tickWidth: 0.75,
labelFlush: false,
tickMinStep: 1000,
})
.scale({
domain: [-200, 4700],
nice: false,
}),
vl.xOffset().value(-11),
vl.y()
.fieldO("D")
.axis({
title: null,
domain: false,
labels: false,
ticks: false,
// minExtent: 80,
})
.scale({
reverse: true,
}),
vl.yOffset().value(-35),
vl.fill()
.fieldN("gesl")
.scale({
range: colors2,
})
.legend(null),
vl.shape()
.fieldN("gesl")
.scale({
range: [
path2,
path1,
]
})
.legend(false)
)
const points = vl
.markPoint({
size: 130,
opacity: 1,
shape: "square",
stroke: "transparent",
})
.encode(
vl.x()
.fieldQ("value")
.axis({
labels: width > 768 ? true : false,
ticks: width > 768 ? true : false,
domain: true,
grid: false,
domainColor: "black",
tickColor: "black",
domainWidth: 0.75,
tickWidth: 0.75,
labelFlush: false,
tickMinStep: 1000,
})
.scale({
domain: [-200, 4700],
nice: false,
}),
vl.y()
.fieldO("D")
.axis({
title: null,
domain: false,
labels: false,
ticks: false,
// minExtent: 80,
})
.scale({
reverse: true,
}),
vl.fill()
.fieldN("gesl")
.scale({
range: colors2,
})
.legend(null),
// .legend({
// // orient: width > 768 ? "right" : "top",
// orient: "top",
// symbolOpacity: 1,
// symbolType: "square",
// title: null,
// }),
)
const lines = vl
.markLine({
opacity: 0.15,
size: 5,
})
.encode(
vl.x()
.fieldQ("value"),
vl.y()
.fieldO("D"),
vl.color()
.fieldN("D")
.scale({
range: ["black", "black"]
})
.legend(null),
)
const text = vl
.markText({
size: 15,
dy: 20,
clip: true,
align: "center",
})
.transform(
vl.joinaggregate([vl.mean("value").as("midValue")])
.groupby(["D"]),
// vl.filter("datum.gesl == 'Marie' || (datum.midValue >= 800)"),
)
.encode(
vl.x()
.fieldQ("value"),
vl.y()
.fieldO("D"),
vl.color()
.fieldN("gesl")
.scale({
range: ["black"],
})
.legend(null),
vl.text()
.fieldQ("value")
.format(",.0f"),
)
const textPerc = vl
.markText({
size: 14,
dx: 48,
dy: -14,
align: "right",
anchor: "middle",
clip: true,
opacity: 0.651,
})
.transform(
vl.joinaggregate([vl.mean("value").as("midValue")])
.groupby(["D"]),
vl.filter("datum.gesl == 'Marie'"),
vl.filter("datum.D == 'Median' || (datum.midValue >= 1000 && datum.midValue <= 3500)")
)
.encode(
vl.x()
.fieldQ("midValue"),
vl.y()
.fieldO("D"),
vl.text()
.fieldQ("gapPerc")
.format(",.0~%"),
)
const textGap = vl
.markText({
size: 14,
dx: 48,
dy: -30,
align: "right",
anchor: "middle",
clip: true,
opacity: 0.651,
})
.transform(
vl.joinaggregate([vl.mean("value").as("midValue")])
.groupby(["D"]),
vl.filter("datum.gesl == 'Marie'"),
vl.filter("datum.D == 'Median' || (datum.midValue >= 1000 && datum.midValue <= 3500)")
)
.encode(
vl.x()
.fieldQ("midValue"),
vl.y()
.fieldO("D"),
vl.text()
.fieldQ("gapValue")
.format("$,.0~f"),
)
const textArrow = vl
.markText({
size: 14,
dx: -48,
dy: -30, // rangeRaw < 3000 ? -35 : -40,
align: "left",
anchor: "middle",
clip: true,
opacity: 0.651,
})
.transform(
vl.joinaggregate([vl.mean("value").as("midValue")])
.groupby(["D"]),
vl.filter("datum.gesl == 'Marie'"),
vl.filter("datum.D == 'Median' || (datum.midValue >= 1000 && datum.midValue <= 3500)")
)
.encode(
vl.x()
.fieldQ("midValue"),
vl.y()
.fieldO("D"),
vl.text()
.value(["Gender", "Gap ↷"]),
)
const textMedianLabel = vl
.markText({
size: 14,
dx: -14,
align: "right",
opacity: 0.651,
})
.transform(
vl.filter("datum.D == 'Median' && datum.gesl == 'Marie'")
)
.encode(
vl.x()
.fieldQ("value"),
vl.y()
.fieldO("D"),
vl.text()
.value("Median"),
)
const textPerzentilLabel = vl
.markText({
size: 14,
dx: -14,
align: "right",
opacity: 0.651,
clip: true,
})
.transform(
vl.filter("datum.D != 'Median' && datum.gesl == 'Marie'")
)
.encode(
vl.x()
.fieldQ("value"),
vl.y()
.fieldO("D"),
vl.text()
.value("Perzentil"),
)
const layers = width > 786 ?
vl.layer(
lines,
labels,
points,
text,
textGap,
textPerc,
textArrow,
textMedianLabel,
textPerzentilLabel,
) :
vl.layer(
lines,
labels,
points,
// textGap,
// textPerc,
// textArrow,
// textMedianLabel,
// textPerzentilLabel,
// text,
)
return layers
.height(width > 786 ? 150 : 150)
.width(makeWidth(width))
.data(inputData)
.config(config)
}import {vl} from "@vega/vega-lite-api-v5" // apiVersion = '5.6.0' vlVersion = '5.6.0' vegaVersion = '5.23.0' tooltipVersion = '0.30.0'
import {aq, op} from '@uwdata/arquero' // version "6.0.0"
import {html} from "htl"binsMid = aq.from(binsRaw)
.filter(d => d.variable == "Status quo")
.derive({
variable: d => aq.op.replace(d.variable, /\r\n/g, " "),
bin: d => d.bin * 100,
range: aq.escape(d => range),
radio: aq.escape(d => radio),
})
cumPercMid = binsMid
.derive({
tail: aq.escape(
d => d.bin < range ? "lower" :
d.bin > range ? "higher" : "same"
)
})
.groupby(["gesl", "variable", "tail"])
.rollup({ N: d => aq.op.sum(d.N) })
.groupby(["gesl", "variable"])
.derive({ perc: d => d.N / aq.op.sum(d.N) })
CumPercSame = cumPercMid
.filter(d => d.tail == "same")
.derive({
percSame: d => d.perc,
})
.select(["gesl", "variable", "percSame"],)
cumPerc = cumPercMid
.join_left(CumPercSame, [["gesl", "variable"], ["gesl", "variable"]])
.derive({
perc: d => d.tail == "lower" ? d.perc + d.percSame : d.perc, // closest decile to the right! not left!!!!
})
.groupby(["gesl", "variable", "tail"])
.derive({
text: aq.escape(d => germanLocale.format(",.0~%")(d.perc)),
percRound: aq.escape(d => Math.round(d.perc*50))
})
.objects()
dataIsotype = cumPerc
.filter(d => d.variable == "Status quo")
.filter(d => d.tail != "same")
// .map(d => ({...d, rangeRaw: germanLocale.format(",.0f")(rangeRaw) }))
function getCumPerc(gesl, tail) {
const out = cumPerc
.filter(d =>
d.gesl == gesl &&
d.tail == tail &&
d.variable == "Status quo"
)
.map(d => d.text)
return out
}
notRadio = radio == "Marie" ? "Otto" : "Marie"
interpolate = "natural"decilesRaw = FileAttachment("data/deciles.csv").csv({ typed: true })
decilesMid = aq.from(decilesRaw)
// .filter(d => d.variable == "Status quo")
.derive({
// select: aq.escape(d => select),
range: aq.escape(d => range),
radio: aq.escape(d => radio),
diff: aq.escape(d => Math.abs(d.value-range)), // closest decile to the right! not left!!!!
})
.groupby(["gesl", "val", "variable"])
.derive({ closestDecile: d => aq.op.min(d.diff) == d.diff && d.gesl == d.radio && d.val == "Status quo" ? 1 : 0 })
.groupby(["gesl", "variable", "D"])
.derive({ closestDecile: d => aq.op.sum(d.closestDecile) > 0 ? "yes" : "no" })
.ungroup()
closestDecileValue = decilesMid
// .filter(aq.escape(d => d.variable == select))
.objects()
.filter(d => d.closestDecile == "yes")
.map(d => d.D)[0]
decilesDiffGesl = decilesMid
.derive({ closestDecileValue: aq.escape(d => closestDecileValue) })
.select(["gesl", "D", "variable", "val", "value"])
.groupby(["val", "D", "variable"])
.pivot("gesl", "value")
.derive({
gapValue: d => d.Otto - d.Marie,
gapPerc: d => (d.Otto - d.Marie) / (d.Otto),
gapRel: d => (d.Marie) / (d.Otto),
})
deciles = decilesMid
.derive({ closestDecileValue: aq.escape(d => closestDecileValue) })
// .join_left(decilesDiffVal, [["gesl", "D", "variable"], ["gesl", "D", "variable"]])
.join_left(decilesDiffGesl, [["val", "D", "variable"], ["val", "D", "variable"]])
.objects()
dataRule = [
{gesl: "Marie", median: 1223, y: 0, y2: 21000},
{gesl: "Otto", median: 2286, y: 0, y2: 21000},
]
medians = aq.from(dataRule)
.select(["gesl", "median"])
dataDistribution = binsMid
.join_left(medians, [["gesl"], ["gesl"]])
.objects()
.filter(d => d.variable == "Status quo")statusIncome = deciles
.filter(d => d.closestDecileValue == d.D && d.gesl == d.radio && d.val == "Status quo")
.map(d => d.value)[0]
closestDecileNumber = closestDecileValue.split('.')[0]otherGenderIncome = deciles
.filter(d => d.closestDecileValue == d.D && d.gesl == notRadio && d.val == "Status quo")
.map(d => d.value)[0]gapAbsolute = Math.abs(otherGenderIncome - statusIncome)
gapRelative = ((otherGenderIncome - statusIncome) / statusIncome) * 100
gapRelativeReverse = ((otherGenderIncome - statusIncome) / otherGenderIncome) * 100
gapDirection = otherGenderIncome > statusIncome ? "mehr" : "weniger"
// For the second sentence, always compare Marie to Otto
marieIncome = radio == "Marie" ? statusIncome : otherGenderIncome
ottoIncome = radio == "Otto" ? statusIncome : otherGenderIncome
// When Marie selected: use Otto as base; when Otto selected: use Marie as base
marieOttoGapPercent = radio == "Marie"
? Math.abs((ottoIncome - marieIncome) / ottoIncome * 100)
: Math.abs((ottoIncome - marieIncome) / ottoIncome * 100)
// Swap percentages when Otto is selected
firstPercent = radio == "Otto" ? marieOttoGapPercent : Math.abs(gapRelative)
secondPercent = radio == "Otto" ? Math.abs(gapRelative) : marieOttoGapPercenttextInteractive2 = radio == "Marie" ?
html`Würden Sie ${notRadio == "Otto" ? "ein" : "eine"} <strong><span style="color: ${otherGenderColor}">${notRadio}</span></strong> sein und im <strong>${closestDecileValue}</strong>, dann würden Sie <strong>${germanLocale.format(",.0f")(otherGenderIncome)}</strong> Euro an Pension bekommen. Das sind <strong>${germanLocale.format(",.0f")(gapAbsolute)}</strong> Euro ${gapDirection} (<strong>${germanLocale.format(",.0f")(firstPercent)}%</strong>). Oder anders herum formuliert: <strong><span style="color: ${colors2[0]}">Marie</span></strong> hätte <strong>${germanLocale.format(",.0f")(secondPercent)}%</strong> weniger als <strong><span style="color: ${colors2[1]}">Otto</span></strong>. So groß ist also der Gender Pension Gap im <strong>${closestDecileValue}</strong>. Im <strong>Median</strong> beträgt er übrigens <strong>47%</strong> oder <strong>1.063</strong> Euro.`
:
html`Würden Sie ${notRadio == "Otto" ? "ein" : "eine"} <strong><span style="color: ${otherGenderColor}">${notRadio}</span></strong> sein und im <strong>${closestDecileValue}</strong>, dann würden Sie <strong>${germanLocale.format(",.0f")(otherGenderIncome)}</strong> Euro an Pension bekommen. Das sind <strong>${germanLocale.format(",.0f")(gapAbsolute)}</strong> Euro ${gapDirection} (<strong>${germanLocale.format(",.0f")(firstPercent)}%</strong>). So groß ist also der Gender Pension Gap im <strong>${closestDecileValue}</strong>. Im <strong>Median</strong> beträgt er übrigens <strong>47%</strong> oder <strong>1.063</strong> Euro.`dataDumbbell = {
// Get closest decile data and drop existing gap columns
const closestDecile = aq.from(deciles)
.params({ closestDecileValue: closestDecileValue, select: select })
.filter(d => d.D == closestDecileValue && d.variable == select && d.val == "Status quo")
.select(aq.not("gapValue", "gapPerc", "gapRel", "Marie", "Otto"))
// Get median data
const medianData = aq.from(dataRule)
.derive({
D: aq.escape(d => "Median"),
value: d => d.median,
variable: aq.escape(d => select),
val: aq.escape(d => "Status quo")
})
// Combine both datasets
const combined = closestDecile.concat(medianData)
// Calculate gaps for each D group
const withGaps = combined
.groupby("D")
.pivot("gesl", "value")
.derive({
gapValue: d => d.Otto - d.Marie,
gapPerc: d => (d.Otto - d.Marie) / d.Otto
})
.select(["D", "gapValue", "gapPerc"])
// Join gaps back and return
return combined
.join_left(withGaps, "D")
.objects()
}genderColor = radio == "Otto" ? colors2[1] : colors2[0]
otherGenderColor = notRadio == "Otto" ? colors2[1] : colors2[0]perzentilText = html`Sie haben eine Bruttopension von <strong>${germanLocale.format(",.0f")(rangeRaw)}</strong> Euro angegeben. Ihre Vergleichsgruppe ist deshalb das <strong>${deciles.map(d => d.closestDecileValue)[0]}</strong> (<strong>${closestDecileNumber}%</strong> der <strong><span style="color: ${genderColor}">${radio}s</span></strong> haben eine geringere Pension). Hier beträgt die durchschnittliche Pension <strong>${germanLocale.format(",.0f")(statusIncome)}</strong> Euro.`textDistributions = html`Etwa <strong>${getCumPerc(radio, "lower")}</strong> aller <strong><span style="color: ${genderColor}">${radio}s</span></strong> erhalten also eine niedrigere Pension als <strong>${germanLocale.format(",.0f")(range)}</strong> Euro pro Monat. Umgekehrt erhalten <strong>${getCumPerc(radio, "higher")}</strong> aller <strong><span style="color: ${genderColor}">${radio}s</span></strong> mehr Pension.`Vor hundert Jahren revolutionierten Marie und Otto Neurath am Wiener Gesellschafts- und Wirtschaftsmuseum die Darstellung komplexer sozialer und ökonomischer Zusammenhänge. Mit ihrer Bildstatistik haben sie unter anderem Verteilungsfragen verständlich dargestellt und damit einer breiteren Öffentlichkeit sichtbar gemacht.
Auch im Jahr 2025 sind diese Verteilungsfragen nicht verschwunden. So zeigen aktuelle Daten, dass der Gender Pension Gap eklatant ist: Das heißt eine heutige Marie erhält in Österreich im Schnitt deutlich weniger Pension als ein heutiger Otto. Die nachfolgende interaktive Visualisierung ermöglicht es, diese Pensionsunterschiede nach Geschlecht zu erkunden. In der Tradition der Neuraths wird komplexe Information zugänglich gemacht, um gesellschaftliche Probleme sichtbar zu machen.
makeIsotype({
inputData: dataIsotype,
rangeValue: germanLocale.format(",.0f")(statusIncome) // or germanLocale.format(",.0f")(rangeRaw)
})
.render({ renderer: "svg" })
Literatur
Eppel, Rainer, Marian Fink, Thomas Horvath, Christine Mayrhuber, und Silvia Rocha-Akis. 2024. „Simulation von Änderungen des Pensionssystems auf die Höhe der Alterseinkommen und den Gender Pension Gap in Österreich“. https://www.wifo.ac.at/publication/270945/.
Mayrhuber, Christine, Lydia Grandner, und Lukas Schmoigl. 2024. „Visualisierungen der Studie: Simulation von Änderungen des Pensionssystems“. WIFO, Österreichisches Institut für Wirtschaftsforschung. https://www.wifo.ac.at/project/427208/.