kpis =FileAttachment("data/kpis.csv").csv({typed:true}).then(d => d[0])semanal =FileAttachment("data/semanal.csv").csv({typed:true})ciudades =FileAttachment("data/ciudades.csv").csv({typed:true})sueldos =FileAttachment("data/sueldos_anuales.csv").csv({typed:true})niveles =FileAttachment("data/niveles.csv").csv({typed:true})contratos =FileAttachment("data/contratos.csv").csv({typed:true})ia =FileAttachment("data/ia.csv").csv({typed:true})tipos =FileAttachment("data/tipo_empleador.csv").csv({typed:true})familias = ["Data Analyst","BI","Data Leadership","Data Scientist","Data Engineer","Business Analyst"]// El nucleo en indigo, violeta y rosa; los vecinos en turquesa, naranja y griscolores = ["#4F46E5","#A855F7","#EC4899","#14B8A6","#F97316","#94A3B8"]fmt = d3.format(",")fecha = d => d.toLocaleDateString("en-AU", {day:"numeric",month:"short",year:"numeric",timeZone:"UTC"})// Las cajitas de los graficos llevan su propio texto (`title`): sin esto, Plot// muestra la fecha como 2026-10-12 y los nombres de las columnas en espanolsemanaTxt = d3.utcFormat("%-d %b %Y")grupoTxt = g => g ==="Core"?"Core data roles":"Neighbour roles"esSi = v => v ===true|| v ==="t"|| v ==="true"porFamilia = d3.rollup(semanal, v => d3.sum(v, d => d.avisos), d => d.familia)nucleo = d3.sum(semanal.filter(d => d.grupo==="Core"), d => d.avisos)
html`<div class="kpis"> <div class="kpi"><div class="v">${fmt(kpis.avisos)}</div><div class="l">data job ads since ${fecha(kpis.desde)}</div></div> <div class="kpi"><div class="v">${fmt(kpis.ultimos_7_dias)}</div><div class="l">posted in the last 7 days</div></div> <div class="kpi"><div class="v">${kpis.pct_junior}%</div><div class="l">are junior or graduate roles</div></div> <div class="kpi"><div class="v">${kpis.pct_con_sueldo}%</div><div class="l">show a real salary</div></div></div>`
Ads are grouped by role family, read from the job title: the core data roles (Data Analyst, BI, Data Leadership) and their neighbours (Data Scientist, Data Engineer, Business Analyst), which are here to compare against. Of the ads, are core data roles.
Demand, week by week
Plot.plot({height:330,marginLeft:50,x: {label:null,tickFormat: d3.utcFormat("%d %b")},y: {label:"New ads per week",grid:true},color: {domain: familias,range: colores,legend:true},marks: [ Plot.rectY(semanal, {x:"semana",interval: d3.utcMonday,y:"avisos",fill:"familia",tip:true,title: d =>`${d.familia}\nWeek of ${semanaTxt(d.semana)}: ${fmt(d.avisos)} ads`}), Plot.ruleY([0]) ]})
The tracker started on 15 October 2026 looking 30 days back, and Adzuna only returns ads that are still open, so the first weeks are undercounted: ads that had already closed never came in. Read the early weeks as a floor, not as a rise in demand. The current week is still running.
pago = sueldos.filter(d => d.nivel==="All levels").sort((a, b) => d3.descending(a.mediana, b.mediana))pagoOk = pago.filter(d =>esSi(d.suficiente))pocos = pago.filter(d =>!esSi(d.suficiente))Plot.plot({height:70+45* pagoOk.length,marginLeft:120,x: {label:"Annual salary in AUD (middle of the advertised range). Bar = middle half of ads",grid:true,tickFormat: d =>`${d /1000}k`},y: {label:null,domain: pagoOk.map(d => d.familia)},marks: [ Plot.ruleY(pagoOk, {x1:"p25",x2:"p75",y:"familia",stroke:"#C7D2FE",strokeWidth:10,strokeLinecap:"round"}), Plot.dot(pagoOk, {x:"mediana",y:"familia",r:7,fill:"#4F46E5",stroke:"#FFFFFF",strokeWidth:2,tip:true,title: d =>`${d.familia}\nmedian AUD ${fmt(d.mediana)}\nmiddle half ${fmt(d.p25)} to ${fmt(d.p75)}\n${d.n} ads`}), Plot.text(pagoOk, {x:"mediana",y:"familia",text: d =>`${fmt(Math.round(d.mediana/1000))}k · n=${d.n}`,dy:-15,fontSize:11}) ]})
html`<p>Only <strong>${kpis.pct_con_sueldo}%</strong> of ads show a real annual, daily or hourly salary,so each figure says how many ads it rests on (<em>n</em>). Families with fewer than 10ads with an annual salary are left out: ${pocos.map(d =>`${d.familia} (${d.n})`).join(", ") ||"none today"}.</p>`
Many ads carry a salary “band” set by the job board, not the employer (most start at exactly AUD 180,000). Those are not counted as salaries: see How it’s made.
Is there room for beginners?
junior = familias.map(f => ({familia: f,pct: (niveles.find(d => d.familia=== f && d.nivel==="Junior") || {pct_de_la_familia:0}).pct_de_la_familia}))Plot.plot({height:270,marginLeft:120,marginRight:45,x: {label:"% of ads that are junior, graduate or entry level",grid:true},y: {label:null,domain: familias},marks: [ Plot.barX(junior, {x:"pct",y:"familia",fill:"#7C3AED",tip:true,title: d =>`${d.familia}: ${d.pct}% of ads are junior`}), Plot.text(junior, {x:"pct",y:"familia",text: d =>`${d.pct}%`,dx:16,fontSize:11}), Plot.ruleX([0]) ]})
Level comes from the job title. More than half of the titles don’t state one, so these shares are a floor.
Plot.plot({height:270,marginLeft:50,x: {label:null,tickFormat: d3.utcFormat("%d %b")},y: {label:"% of ads mentioning AI",grid:true},color: {domain: ["Core","Neighbour"],range: ["#4F46E5","#F97316"],legend:true},marks: [ Plot.lineY(ia.filter(d => d.grupo!=="All"), {x:"semana",y:"pct",stroke:"grupo",strokeWidth:2.5,marker:true,tip:true,title: d =>`${grupoTxt(d.grupo)}\nWeek of ${semanaTxt(d.semana)}: ${d.pct}% of ${fmt(d.avisos)} ads`}), Plot.ruleY([0]) ]})
Adzuna only shares the first ~500 characters of each ad, so this counts the ads that mention AI at least that early.
Who posts the ads?
clasificados = tipos.filter(d => d.tipo_empleador!=="Unclassified")totalClasificados = d3.sum(clasificados, d => d.avisos)sinClasificar = tipos.find(d => d.tipo_empleador==="Unclassified") || {avisos:0,pct:0}reparto = clasificados.map(d => ({...d,pct:Math.round(1000* d.avisos/ totalClasificados) /10}))Plot.plot({height:170,marginLeft:120,marginRight:45,x: {label:"% of ads from classified companies",grid:true},y: {label:null},marks: [ Plot.barX(reparto, {x:"pct",y:"tipo_empleador",sort: {y:"x",reverse:true},fill:"#A855F7",tip:true,title: d =>`${d.tipo_empleador}: ${d.pct}% (${fmt(d.avisos)} ads)`}), Plot.text(reparto, {x:"pct",y:"tipo_empleador",text: d =>`${d.pct}%`,dx:18,fontSize:11}), Plot.ruleX([0]) ]})
html`<p>Among the ${fmt(totalClasificados)} ads whose company has been classified so far, the chart aboveshows who posted them. The other ${fmt(sinClasificar.avisos)} ads (${sinClasificar.pct}%) come fromcompanies not yet classified: they are marked by hand, starting with the ones that post the most, andthe rest are left out rather than guessed.</p>`