Centrada Statistieken

Krijg inzicht in de data rondom je reacties op Centrada.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Centrada Statistieken
// @namespace    http://tampermonkey.net/
// @version      2025-03-22
// @description  Krijg inzicht in de data rondom je reacties op Centrada.
// @author       Mubbletm
// @match        https://woningzoeken.centrada.nl/portaal/mijn-reacties/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=centrada.nl
// @grant        none
// @license MIT
// ==/UserScript==

// Common filters to use as array filter.
const filters = {
    lotingen: o => o.verdeelmethode === 'Loting',
    inschrijfduur: o => o.verdeelmethode === 'Inschrijfduur',
    top(percentage) {
        return o => (o.eindpositie / o.aantalreacties) < percentage;
    }
}

// Downloads given text as file.
function download(filename, text) {
    var element = document.createElement('a');
    element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
    element.setAttribute('download', filename);

    element.style.display = 'none';
    document.body.appendChild(element);

    element.click();

    document.body.removeChild(element);
}

// Insert script's buttons.
function insertButton() {
    const historieHeader = document.querySelector('div.js-reacties-historie > div.box > h2');
    historieHeader.insertAdjacentHTML('afterend', '<div id="statistics-script" style="display: flex; flex-direction: row; gap: 10px;"><button id="CSVDownload" class="c-button  c-button--red c-button--large  c-button--pointer">Download CSV</button><button id="statistics" style="margin: 0" class="c-button  c-button--red c-button--large  c-button--pointer">Statistieken weergeven</button></div>');
    const CSVbtn = document.querySelector('#CSVDownload');
    CSVbtn.addEventListener('click', async () => {
        const stop = startDownload('CSVDownload');
        const reacties = await fetchReacties();
        stop();
        download('CentradaHistorischeReacties.csv', convertReactiesToCSV(reacties));
    });
    const statsBtn = document.querySelector('#statistics');
    statsBtn.addEventListener('click', () => {
        statsBtn.disabled = true;
        showStatistics()
    });
}

let _statistiek;

async function showStatistics() {
    const stop = startDownload('statistics');
    const reacties = (await fetchReacties()).filter(o => o.eindpositie !== 0);
    const lotingen = reacties.filter(filters.lotingen);
    const inschrijfduur = reacties.filter(filters.inschrijfduur);
    let data = inschrijfduur.map(o => [+(new Date(o.publstop)), o.eindpositie / o.aantalreacties]);

    // https://math.stackexchange.com/questions/204020/what-is-the-equation-used-to-calculate-a-linear-trendline
    const sumXtimesY = data.reduce(((acc, cur) => acc + (cur[0] * cur[1])), 0);
    const sumX = data.reduce(((acc, cur) => acc + cur[0]), 0);
    const sumY = data.reduce(((acc, cur) => acc + cur[1]), 0);
    const sumXsquared = data.reduce(((acc, cur) => acc + (cur[0] ** 2)), 0);
    const slope = ((data.length * sumXtimesY) - (sumX * sumY)) / ((data.length * sumXsquared) - (sumX ** 2));
    const offset = (sumY - (slope * sumX)) / data.length;
    const trendX = (-offset) / slope;
    const firstX = data.reduce(((acc, cur) => Math.min(acc, cur[0])), Number.MAX_VALUE);
    const trendY = (slope * firstX) + offset;

    const eersteDatum = new Date(oudsteReactie(reacties).datumreactie);
    const maanden = ['Januari', 'Februari', 'Maart', 'April', 'Mei', 'Juni', 'Juli', 'Augustus', 'September', 'Oktober', 'November', 'December'];

    const statistics = [
        [`Voorspelde wachttijd om een inschrijfduur woning te krijgen sinds ${maanden[eersteDatum.getMonth()]} ${eersteDatum.getFullYear()} (datum van je eerste reactie)`, formatPeriode(Math.floor((trendX - (+eersteDatum)) / 1000 / 60 / 60 / 24))],
        ['Voorspelde betaalde registratiekosten bij het krijgen van je eerste inschrijfwoning', `€${15 * Math.ceil((trendX - (+eersteDatum)) / 1000 / 60 / 60 / 24 / 365)},00`],
        ['Aantal reacties op woningen', reacties.length],
        ['Aantal woningen op inschrijfduur', inschrijfduur.length],
        ['Aantal woningen op loting', lotingen.length],
        ['Inschrijfduur/Loting ratio', woningTypeRatio(reacties, 'Inschrijfduur', 'Loting').join(' / ')],
        ['Laagst behaalde positie', laagstePositie(reacties)],
        ['Aantal keer in de top 5%', reacties.filter(filters.top(.05)).length],
        ['Aantal keer in de top 10%', reacties.filter(filters.top(.1)).length],
        ['Aantal keer in de top 25%', reacties.filter(filters.top(.25)).length],
        ['Aantal keer in de top 50%', reacties.filter(filters.top(.5)).length],
        ['Gemiddeld aantal reacties per woning totaal/inschrijfduur/loting', [
            gemiddeldAantalReacties(reacties),
            gemiddeldAantalReacties(inschrijfduur),
            gemiddeldAantalReacties(lotingen)
        ].join(' / ')],
        ['Gemiddelde positie totaal/inschrijfduur/loting', [
            gemiddeldePositie(reacties),
            gemiddeldePositie(inschrijfduur),
            gemiddeldePositie(lotingen)
        ].join(' / ')],
        ['Gemiddelde positie ten opzichte van aantal reacties totaal/inschrijfduur/loting', [
            gemiddeldeRelatievePositie(reacties),
            gemiddeldeRelatievePositie(inschrijfduur),
            gemiddeldeRelatievePositie(lotingen)
        ].join(' / ')],
        ['Periode op de wachtlijst sinds 1e reactie', tijdTussenNuEnOudsteReactie(reacties) ],
    ];
    document.querySelector('#statistics-script').insertAdjacentHTML('afterend', `
    <div id="chart" style="width: 100%; margin-top: 20px; height: 200px;"></div>
    <table class="u-full-width">
  <tbody>
    <tr>
      <th>Statistiek</th>
      <th>Waarde</th>
    </tr>
  </tbody>
  ${statistics.map(o => `<tr>${o.map(d => `<td style="color: #4a4a4a;">${d}</td>`).join('\n')}</tr>`).join('\n')}
</table><a id="StatDownload" href="javascript:void(0)" class="c-thumblist__link">Download Statistieken als CSV</a>`);
    let script = document.createElement('script');
    script.onload = function () {
        let options = {
            chart: {
                type: 'line',
                height: 500,
                toolbar: {
                    show: false
                },
                zoom: {
                    enabled: false
                },
            },
            stroke: {
                width: 2
            },
            series: [{
                name: 'Top % van reacties bij inschrijfduur',
                data
            },
                     {
                         name: 'Trend',
                         data: [
                             [firstX, trendY],
                             [trendX, 0]
                         ]
                     }],
            xaxis: {
                type: 'datetime',
                max: trendX
            },
            yaxis: {
                min: 0,
                max: 1,
                tickAmount: 4,
                decimalsInFloat: 2
            },
            annotations: {
                xaxis: [
                    {
                        x: trendX,
                        strokeDashArray: 0,
                        borderColor: "#775DD0",
                        label: {
                            borderColor: "#775DD0",
                            style: {
                                color: "#fff",
                                background: "#775DD0"
                            },
                            text: (new Date(trendX)).toDateString()
                        }
                    }
                ]
            }
        }

        let chart = new ApexCharts(document.querySelector("#chart"), options);

        chart.render();
    };
    script.src = "https://cdn.jsdelivr.net/npm/apexcharts";

    document.head.appendChild(script);
    stop();
    _statistiek = statistics;
    document.querySelector('#StatDownload').addEventListener('click', async () => {
        download('CentradaHistorischeReactiesStatistiek.csv', ['Statistiek', 'Waarde'].join(';') + '\n' + statistics.map(arr => arr.join(';')).join('\n'));
    });
}

// Give visual feedback to show the user that the request is still ongoing.
function startDownload(id) {
    const el = document.querySelector(`#${id}`);
    const defaultText = el.innerText;
    const defaultState = el.disabled;
    let isDownloading = true;
    (async () => {
        el.disabled = true;
        el.innerText = 'Downloading';
        let dotsCount = 0;
        while (isDownloading) {
            await new Promise(res => setTimeout(res, 300));
            dotsCount++;
            if (dotsCount > 3) dotsCount = 0;
            el.innerText = `Downloading${new Array(dotsCount).fill('.').join('')}`;
        }
        el.innerText = defaultText;
        el.disabled = defaultState;
    })();
    return function stop() {
        isDownloading = false;
    }
}

// Fetch past reactions, authorization is done with cookies that are already present in the browser.
function fetchPage(count = 100, page = 1) {
    return fetch('/WebServices/Reacties.asmx/GetHistorischeReacties', {method: 'POST', body: JSON.stringify({aantalreacties: count, pagenumber: page}), headers: {'content-type': 'application/json'}});
}

function fetchOnGoing() {
    return fetch('/Webservices/Reacties.asmx/GetReactiesInBehandeling', {method: 'POST', headers: {'content-type': 'application/json'}});
}

let _reacties;

// In case there's more than 100 reactions, gather all from pagination.
async function fetchReacties() {
    if (_reacties) return _reacties;
    const count = 100;
    let page = 0;
    let reacties = ((await (await fetchOnGoing()).json()).d.reacties) || [];
    const offset = reacties.length;
    while (page * count === reacties.length - offset) {
        reacties = [...reacties, ...((await (await fetchPage(count, ++page)).json()).d.reacties)];
    }
    _reacties = reacties;
    return reacties;
}

function convertReactiesToCSV(reacties) {
    return ['wijk', 'adres', 'verdeelmethode', 'aantal reacties', 'positie', 'datum'].join(';') + '\n' +
        reacties.map(o => [o.wijk, o.adres, o.verdeelmethode, o.aantalreacties, o.eindpositie, o.publstop].join(';')).join('\n');
}

if (document.readyState !== 'loading') {
    insertButton();
} else {
    document.addEventListener('DOMContentLoaded', insertButton);
}

// =============================================================================================
//
//                                          Statistieken
//
// =============================================================================================

function gemiddeldAantalReacties(reacties) {
    return Math.round(reacties.reduce(((acc, cur) => acc + cur.aantalreacties), 0) / reacties.length);
}

function gemiddeldePositie(reacties) {
    return Math.round(reacties.reduce(((acc, cur) => acc + cur.eindpositie), 0) / reacties.length);
}

function gemiddeldeRelatievePositie(reacties) {
    const percentages = reacties.map(o => o.eindpositie / o.aantalreacties);
    const average = percentages.reduce((acc, cur) => acc + cur) / reacties.length;
    return Math.round(average * 100) + '%';
}

function oudsteReactie(reacties) {
    return reacties.reduce(((acc, cur) => {
        const smallest = Math.min(new Date(acc.publstop), new Date(cur.publstop))
        if (+(new Date(acc.publstop)) === smallest) return acc;
        return cur;
    }));
}

function formatPeriode(dagen) {
    const jaren = Math.floor(dagen / 365);
    dagen %= 365;
    return `${jaren > 0 ? `${jaren} jaar ${dagen > 0 ? 'en ': ''}`: ''}${dagen > 0 || jaren === 0 ? `${dagen} ${dagen === 1 ? 'dag':'dagen'}`:''}`
}

function tijdTussenNuEnOudsteReactie(reacties) {
    let dagen = Math.floor((new Date() - new Date(oudsteReactie(reacties).datumreactie)) / 1000 / 60 / 60 / 24);
    return formatPeriode(dagen);
}

function woningTypeRatio(reacties, ...woningTypes) {
    return woningTypes.map(type => `${Math.round(reacties.filter(reactie => reactie.verdeelmethode === type).length / reacties.length * 100)}%`);
}

function laagstePositie(reacties) {
    return reacties.reduce(((acc, cur) => Math.min(cur.eindpositie, acc)), 1000);
}