Centrada Statistieken

Krijg inzicht in de data rondom je reacties op Centrada.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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);
}