) {
5 | var classList = document.querySelectorAll('body')[0].classList
6 | for (var key in this.properties) {
7 | classList.remove(key)
8 | }
9 |
10 | this.properties = properties
11 |
12 | for (var key in this.properties) {
13 | if (this.is(key))
14 | classList.add(key)
15 | }
16 | }
17 |
18 | is(type: string): boolean {
19 | return !!(this.properties as any)[type] // FIXME as any
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/_stylesheets/_animations.scss:
--------------------------------------------------------------------------------
1 | .animate__tracking-in-expand {
2 | animation: tracking-in-expand 0.2s ease-in both;
3 | }
4 |
5 | @keyframes tracking-in-expand {
6 | 0% {
7 | letter-spacing: -0.5em;
8 | opacity: 0;
9 | }
10 | 40% {
11 | opacity: 0.6;
12 | }
13 | 100% {
14 | opacity: 1;
15 | }
16 | }
17 |
18 | @keyframes main-menu-slide-bottom {
19 | 0% {
20 | transform: translateY(-6px);
21 | }
22 | 100% {
23 | transform: translateY(0);
24 | }
25 | }
26 |
27 | @keyframes rotate-in-center {
28 | 0% {
29 | transform: rotate(-360deg);
30 | }
31 | 100% {
32 | transform: rotate(0);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/assets/twitter.svg:
--------------------------------------------------------------------------------
1 | Twitter icon
--------------------------------------------------------------------------------
/src/index.hbs:
--------------------------------------------------------------------------------
1 | {{#> _layout/page pageType="tabulasi" name="tabulasi" }}
2 | {{#*inline "content"}}
3 |
4 | Hasil Tabulasi Data Kawal Pemilu 2019
5 |
6 | {{!-- Halaman ini berisi hasil tabulasi dari data yang sudah masuk ke
7 | upload.kawalpemilu.org .
--}}
8 |
9 | {{!-- Data TPS kamu belum ada? Segera upload ke
10 | upload.kawalpemilu.org !
11 |
--}}
12 |
13 |
14 |
15 | Data pada halaman ini sudah final dan
16 | tidak akan diperbarui lagi.
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {{/inline}}
25 | {{/_layout/page}}
26 |
--------------------------------------------------------------------------------
/src/tabulasi/tabulasi.scss:
--------------------------------------------------------------------------------
1 | @import "../sizes";
2 |
3 | div.pengumuman {
4 | display: inline-block;
5 |
6 | margin: 0 0 20px 10px;
7 | padding: 10px 15px;
8 |
9 | p {
10 | margin: 0 0 10px 0;
11 | @media #{$phone} {
12 | margin-bottom: 5px;
13 | }
14 | }
15 |
16 | p:last-child {
17 | margin: 0px;
18 | }
19 |
20 | &.warn {
21 | border: 1px solid #e57373;
22 | border-width: 1px 0;
23 | background: #ffebee;
24 | }
25 |
26 | @media #{$mobile} {
27 | display: block;
28 | padding: 10px 10px;
29 | margin-left: 0px;
30 | }
31 | @media #{$phone} {
32 | margin-bottom: 10px;
33 | font-size: 14px;
34 | span.desktop {
35 | display: none;
36 | }
37 | }
38 | }
39 |
40 | main {
41 | article {
42 | p {
43 | @media #{$phone} {
44 | line-height: 20px;
45 | }
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/assets/logo/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "KawalPemilu - Jaga Suara 2019",
3 | "icons": [
4 | {
5 | "src": "\/android-icon-36x36.png",
6 | "sizes": "36x36",
7 | "type": "image\/png",
8 | "density": "0.75"
9 | },
10 | {
11 | "src": "\/android-icon-48x48.png",
12 | "sizes": "48x48",
13 | "type": "image\/png",
14 | "density": "1.0"
15 | },
16 | {
17 | "src": "\/android-icon-72x72.png",
18 | "sizes": "72x72",
19 | "type": "image\/png",
20 | "density": "1.5"
21 | },
22 | {
23 | "src": "\/android-icon-96x96.png",
24 | "sizes": "96x96",
25 | "type": "image\/png",
26 | "density": "2.0"
27 | },
28 | {
29 | "src": "\/android-icon-144x144.png",
30 | "sizes": "144x144",
31 | "type": "image\/png",
32 | "density": "3.0"
33 | },
34 | {
35 | "src": "\/android-icon-192x192.png",
36 | "sizes": "192x192",
37 | "type": "image\/png",
38 | "density": "4.0"
39 | }
40 | ]
41 | }
--------------------------------------------------------------------------------
/src/tabulasi/page.ts:
--------------------------------------------------------------------------------
1 | import { PageParam } from './common'
2 | import { HierarchyNode } from './types'
3 | import { NavRenderer } from './nav'
4 | import { TpsRenderer } from './tps'
5 | import { AggRenderer } from './agg';
6 | import { ScreenSize } from './screen'
7 |
8 | export class PageRenderer {
9 | private navRenderer: NavRenderer
10 | private aggRenderer: AggRenderer
11 | private tpsRenderer: TpsRenderer
12 |
13 | constructor(
14 | screenSize: ScreenSize,
15 | nav: HTMLElement,
16 | agg: HTMLElement,
17 | tps: HTMLElement) {
18 |
19 | this.navRenderer = new NavRenderer(screenSize, nav)
20 | this.aggRenderer = new AggRenderer(screenSize, agg)
21 | this.tpsRenderer = new TpsRenderer(screenSize, tps)
22 | }
23 |
24 | render(param: PageParam, node: HierarchyNode) {
25 | this.navRenderer.render(param, node)
26 | this.tpsRenderer.render(param, node)
27 | this.aggRenderer.render(param, node)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/_layout/page.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | {{#if title}}
10 | {{ title }} | KawalPemilu - Jaga Suara 2019
11 | {{else}}
12 | KawalPemilu - Jaga Suara 2019
13 | {{/if}}
14 |
15 | {{> head-social.hbs }}
16 | {{> head-ga.hbs }}
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {{> body-header.hbs }}
25 |
26 |
27 |
28 |
29 | {{#> content}}
30 | {{/content}}
31 |
32 |
33 |
34 |
35 | {{> body-footer.hbs }}
36 | {{#> scripts}}
37 | {{/scripts}}
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/tentang.hbs:
--------------------------------------------------------------------------------
1 | {{#> _layout/static title="Tentang Kawal Pemilu" name="tentang" }}
2 | {{#*inline "content"}}
3 |
4 | Tentang Kawal Pemilu
5 |
6 | Kawal Pemilu adalah proyek urun daya (crowdsourcing ) netizen pro data
7 | Indonesia yang didirikan tahun 2014 untuk menjaga suara rakyat di Pemilihan
8 | Umum melalui penggunaan teknologi untuk melakukan real count secara cepat dan
9 | akurat.
10 |
11 | Pada Pemilu 2019, KawalPemilu.org merupakan mitra dari
12 | Netgrit (Network for
13 | Democracy and Electoral Integrity) dalam gerakan KawalPemilu - Jaga Suara
14 | 2019 . Kemitraan ini resmi terakreditasi oleh Bawalsu RI . Selain dari
16 | kemitraan ini, situs KawalPemilu.org tidak terafiliasi dengan pihak manapun.
17 |
18 |
19 |
22 |
23 | {{/inline}}
24 | {{/_layout/static}}
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 KawalPemilu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "kawalpemilu",
3 | "version": "2019.0.0",
4 | "description": "",
5 | "private": true,
6 | "scripts": {
7 | "build": "webpack --config webpack.prod.js",
8 | "start": "webpack-dev-server --open --config webpack.dev.js"
9 | },
10 | "author": "Kawal Pemilu",
11 | "devDependencies": {
12 | "autoprefixer": "^9.5.1",
13 | "clean-webpack-plugin": "^2.0.1",
14 | "copy-webpack-plugin": "^5.0.3",
15 | "css-loader": "^2.1.1",
16 | "handlebars": "^4.3.0",
17 | "handlebars-loader": "^1.7.1",
18 | "html-webpack-plugin": "^3.2.0",
19 | "mini-css-extract-plugin": "^0.6.0",
20 | "node-sass": "^4.12.0",
21 | "optimize-css-assets-webpack-plugin": "^5.0.1",
22 | "postcss-loader": "^3.0.0",
23 | "sass-loader": "^7.1.0",
24 | "style-loader": "^0.23.1",
25 | "ts-loader": "^5.4.4",
26 | "typescript": "^3.4.5",
27 | "webpack": "^4.30.0",
28 | "webpack-cli": "^3.3.1",
29 | "webpack-dev-server": "^3.3.1",
30 | "webpack-merge": "^4.2.1"
31 | },
32 | "dependencies": {
33 | "@types/debounce": "^1.2.0",
34 | "debounce": "^1.2.0"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/assets/whatsapp.svg:
--------------------------------------------------------------------------------
1 | WhatsApp icon
--------------------------------------------------------------------------------
/src/assets/overlay.svg:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/tabulasi/nav.scss:
--------------------------------------------------------------------------------
1 | @import "../sizes";
2 |
3 | .table-type-nav {
4 | a {
5 | text-decoration: none;
6 | }
7 |
8 | ul {
9 | border-bottom: 1px solid #dfe3e8;
10 | display: flex;
11 | list-style: none;
12 | margin: 0;
13 | padding: 0;
14 |
15 | li {
16 | display: flex;
17 | flex: 1 1 100%;
18 |
19 | a {
20 | border-bottom: 3px solid transparent;
21 | color: #666;
22 | display: block;
23 | font-weight: 700;
24 | justify-content: center;
25 | padding: 10px 15px;
26 | text-align: center;
27 | width: 100%;
28 |
29 | &:hover {
30 | border-bottom-color: #dfe3e8;
31 | color: #333;
32 | }
33 | }
34 |
35 | &.active {
36 | a {
37 | border-bottom: 3px solid #5c6ac4;
38 | color: #333;
39 | }
40 | }
41 | }
42 | }
43 | }
44 |
45 | .breadcrumbs {
46 | background-color: #F4F6F8;
47 | font-size: 12px;
48 | padding: 10px;
49 |
50 | span.nav {
51 | white-space: nowrap;
52 | }
53 | span.sep {
54 | padding-left: 5px;
55 | padding-right: 5px;
56 | }
57 | a {
58 | text-decoration: none;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/_stylesheets/_footer.scss:
--------------------------------------------------------------------------------
1 | .global-footer {
2 | color: #666;
3 | height: 120px;
4 | display: flex;
5 | flex-direction: row;
6 | font-size: 12px;
7 | justify-content: space-between;
8 | padding: 35px 10px 20px;
9 |
10 | .secondary {
11 | display: flex;
12 | flex-direction: column;
13 |
14 | p {
15 | font-weight: 700;
16 | letter-spacing: 1px;
17 | margin: 0 0 10px;
18 | text-transform: uppercase;
19 | }
20 | }
21 |
22 | .footer-menu {
23 | align-items: center;
24 | display: flex;
25 | flex-direction: row;
26 | margin: 0;
27 | padding: 0;
28 |
29 | li {
30 | border-right: 1px solid #c4cdd5;
31 | flex: 0 0 auto;
32 | list-style-type: none;
33 | padding: 0 10px;
34 |
35 | &:first-child {
36 | padding-left: 0;
37 | }
38 | &:last-child {
39 | border-right: none;
40 | }
41 |
42 | a {
43 | display: block;
44 | }
45 | }
46 | }
47 |
48 | .social {
49 | li {
50 | border-right: none;
51 |
52 | a {
53 | img {
54 | filter: opacity(0.7);
55 | }
56 | &:hover img {
57 | filter: opacity(1);
58 | }
59 | }
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/_layout/body-header.hbs:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------
/src/_layout/head-social.hbs:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/_layout/body-footer.hbs:
--------------------------------------------------------------------------------
1 |
31 |
--------------------------------------------------------------------------------
/src/page.scss:
--------------------------------------------------------------------------------
1 | @import "sizes";
2 | @import "_stylesheets/animations";
3 | @import "_stylesheets/header";
4 | @import "_stylesheets/content";
5 | @import "_stylesheets/footer";
6 |
7 | body,
8 | html {
9 | background-color: #f4f6f8;
10 | font-family: 'Open Sans', sans-serif;
11 | margin: 0px;
12 | padding: 0px;
13 | height: 100%;
14 | width: 100%;
15 | min-width: 320px;
16 | }
17 |
18 | body,
19 | html,
20 | table,
21 | th,
22 | td {
23 | font-size: 16px;
24 | }
25 |
26 | body {
27 | display: flex;
28 | flex-direction: column;
29 | }
30 |
31 | h1 {
32 | margin: 0px;
33 | font-size: 32px;
34 |
35 | @media #{$phone-small} {
36 | font-size: 20px;
37 | }
38 | @media #{$phone-wide} {
39 | font-size: 24px;
40 | }
41 | }
42 |
43 | a {
44 | color: #007ACE;
45 | }
46 |
47 | a:hover {
48 | color: #084E8A;
49 | }
50 |
51 | .nowrap {
52 | white-space: nowrap;
53 | }
54 |
55 | /** screen-reader **/
56 | .sr {
57 | position: absolute;
58 | width: 1px;
59 | height: 1px;
60 | padding: 0;
61 | margin: -1px;
62 | overflow: hidden;
63 | clip: rect(0, 0, 0, 0);
64 | border: 0;
65 | }
66 |
67 | /** container **/
68 | .container {
69 | margin-left: auto;
70 | margin-right: auto;
71 | width: 100%;
72 |
73 | @media (min-width: 780px) {
74 | width: 90%;
75 | }
76 | @media (min-width: 1340px) {
77 | max-width: 1260px;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | firebase-debug.log*
8 |
9 | # Firebase cache
10 | .firebase/
11 |
12 | # Firebase config
13 |
14 | # Uncomment this if you'd like others to create their own Firebase project.
15 | # For a team working on the same Firebase project(s), it is recommended to leave
16 | # it commented so all members can deploy to the same project(s) in .firebaserc.
17 | # .firebaserc
18 |
19 | # Runtime data
20 | pids
21 | *.pid
22 | *.seed
23 | *.pid.lock
24 |
25 | # Directory for instrumented libs generated by jscoverage/JSCover
26 | lib-cov
27 |
28 | # Coverage directory used by tools like istanbul
29 | coverage
30 |
31 | # nyc test coverage
32 | .nyc_output
33 |
34 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
35 | .grunt
36 |
37 | # Bower dependency directory (https://bower.io/)
38 | bower_components
39 |
40 | # node-waf configuration
41 | .lock-wscript
42 |
43 | # Compiled binary addons (http://nodejs.org/api/addons.html)
44 | build/Release
45 |
46 | # Dependency directories
47 | node_modules/
48 |
49 | # Optional npm cache directory
50 | .npm
51 |
52 | # Optional eslint cache
53 | .eslintcache
54 |
55 | # Optional REPL history
56 | .node_repl_history
57 |
58 | # Output of 'npm pack'
59 | *.tgz
60 |
61 | # Yarn Integrity file
62 | .yarn-integrity
63 |
64 | # dotenv environment variables file
65 | .env
66 |
67 | # vim
68 | *.swp
69 |
70 | _build/
71 | _staging/
72 | _public/
73 |
74 | dist/
75 |
--------------------------------------------------------------------------------
/src/privasi.hbs:
--------------------------------------------------------------------------------
1 | {{#> _layout/static title="Kebijakan Privasi" name="privasi" }}
2 | {{#*inline "content"}}
3 |
4 | Kebijakan Privasi
5 |
6 | Berikut ini kebijakan privasi yang dipakai oleh Kawal Pemilu dalam menggunakan
7 | dan melindungi data pribadi Anda.
8 |
9 |
10 |
11 | Kawal Pemilu mewajibkan seluruh relawan dan moderator memiliki akun Facebook
12 | dengan profil yang jelas untuk memastikan tidak adanya relawan dan moderator
13 | yang fiktif.
14 |
15 |
16 | Seluruh relawan dan moderator wajib masuk kedalam akun Facebook terlebih
17 | dahulu saat mengakses laman Kawal Pemilu.
18 |
19 |
20 | Moderator dapat mengakses profil Facebook milik relawan guna melakukan
21 | validasi dari masing-masing relawan. Hak akses ke halaman profil akan selalu
22 | dibatasi oleh Facebook berdasarkan hubungan pertemanan dan pengaturan siapa
23 | yang bisa mengakses profil.
24 |
25 |
26 | Seluruh data profil relawan dan moderator tidak dapat disebarkan oleh Kawal
27 | Pemilu kepada pihak lain.
28 |
29 |
30 | Kawal Pemilu dalam mengelola akun milik relawan dan moderator mengikuti
31 | ketentuan layanan dan keamanan dari Facebook.
32 |
33 |
34 |
35 | Perbedaan antara relawan dan moderator dapat dibaca pada halaman
36 | Jenis/Peran Pengunjung .
37 |
38 | {{/inline}}
39 | {{/_layout/static}}
40 |
--------------------------------------------------------------------------------
/src/visualisasi.hbs:
--------------------------------------------------------------------------------
1 | {{#> _layout/static title="Visualisasi Data" name="visualisasi" }}
2 | {{#*inline "content"}}
3 |
4 | Visualisasi Data
5 |
6 | Untuk melihat hasil visualisasi data yang
7 | sedang dikumpulkan oleh Kawal Pemilu ,
8 | silakan kunjungi tautan berikut ini.
9 |
10 | PERHATIAN Data yang berhasil dikumpulkan sampai saat ini masih sangat kecil
11 | dan belum dapat disebut data yang representatif. Silakan gunakan hasil
12 | visualisasi dengan cermat dan bijak.
13 |
14 | PERHATIAN #2 Data yang digunakan untuk visualisasi belum tentu data yang
15 | paling mutakhir yang dapat dilihat pada kawalpemilu.org .
16 | Ada keterlambatan 30+ menit dibanding data yang disajikan pada
17 | kawalpemilu.org .
18 |
19 |
30 |
31 | {{/inline}}
32 | {{/_layout/static}}
33 |
--------------------------------------------------------------------------------
/src/tabulasi/nav.ts:
--------------------------------------------------------------------------------
1 | import { PageParam, PageTypes } from './common'
2 | import { HierarchyNode } from './types'
3 | import { ScreenSize } from './screen';
4 |
5 | export class NavRenderer {
6 | constructor(
7 | private screenSize: ScreenSize,
8 | private target: HTMLElement) { }
9 |
10 | render(param: PageParam, node: HierarchyNode) {
11 | this.target.innerHTML = this._render(param, node)
12 | }
13 |
14 | private _render(param: PageParam, node: HierarchyNode) {
15 | return [
16 | '',
17 | this._getTableTypeNav(param),
18 | '
',
19 |
20 | '',
21 | this._getBreadcrumbs(param, node),
22 | '
',
23 | ].join('')
24 | }
25 |
26 | private _getTableTypeNav(param: PageParam) {
27 | const { type, id } = param
28 |
29 | let s = ''
30 | PageTypes.forEach(t => {
31 | const className = t === type
32 | ? ' class="active"'
33 | : ''
34 |
35 | const hash = `#${t}:${id}`
36 | const name = t === 'pileg'
37 | ? 'DPR'
38 | : 'Presiden'
39 |
40 | s += `${name} `
41 | })
42 |
43 | return ``
44 | }
45 |
46 | private _getBreadcrumbs(param: PageParam, node: HierarychNode) {
47 | var s = ''
48 | for (var i = 0; i < node.parentIds.length; i++) {
49 | var pid = node.parentIds[i]
50 | var name = node.parentNames[i]
51 | var hash = '#' + param.type + ':' + pid
52 | s += `${name} › `
53 | }
54 | s += `${node.name} `
55 | return s
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/tabulasi/sort.ts:
--------------------------------------------------------------------------------
1 | import { PartaiEntries } from "./agg-pileg"
2 | import { updateStickyTableColumn } from "./sticky";
3 |
4 | export function setupSort(agg: HTMLElement) {
5 |
6 | setupColumn(agg, "tr.header td.idx", "td.idx", true);
7 | setupColumn(agg, "tr.header td.name", "td a", false);
8 |
9 | setupColumn(agg, "tr.header td.pas1", "td.sum.pas1 span.abs", true);
10 | setupColumn(agg, "tr.header td.pas2", "td.sum.pas2 span.abs", true);
11 | setupColumn(agg, "tr.header td.estimasi", "td.estimasi span", true);
12 |
13 | PartaiEntries.forEach((e) => {
14 | setupColumn(agg, "tr.header td." + e.field, "td." + e.field, true);
15 | });
16 | }
17 |
18 | function setupColumn(agg: HTMLElement, header: string, rowSelect: string, numeric: boolean) {
19 | let headerElem = agg.querySelector(header);
20 | let rows = agg.querySelectorAll('tr.row');
21 | let footer = agg.querySelector("tr.footer");
22 |
23 | if (headerElem) {
24 | headerElem.addEventListener("click", (e: Event) => {
25 | var dir = headerElem.classList.toggle('sort-up') ? 1 : -1;
26 | var rSorted = [].slice.call(rows).sort((ra: HTMLElement, rb: HTMLElement) => {
27 | var va = rowValue(ra, rowSelect, numeric);
28 | var vb = rowValue(rb, rowSelect, numeric);
29 | return va > vb ? dir : -dir;
30 | });
31 |
32 | var tBody = rows[0].parentNode;
33 | for (var i = 0; i < rows.length; i++) {
34 | tBody.insertBefore(rSorted[i], footer);
35 | }
36 | updateStickyTableColumn();
37 | });
38 | }
39 | }
40 |
41 | function rowValue(row: HTMLElement, selector: string, numeric: boolean) {
42 | var el: HTMLElement = row.querySelector(selector)
43 | var res = el.innerText
44 | if (!numeric) return res;
45 |
46 | // normalize thousands
47 | if (res.indexOf(".")) res = res.replace(/\./g, "")
48 |
49 | // normalize percents with comma
50 | res = res.replace("%", "")
51 | res = res.replace(",", ".")
52 |
53 | return Number(res);
54 | }
55 |
--------------------------------------------------------------------------------
/src/jenis-peran-pengunjung.hbs:
--------------------------------------------------------------------------------
1 | {{#> _layout/static title="Jenis/Peran Pengunjung" name="roles" }}
2 | {{#*inline "content"}}
3 |
4 | Jenis/Peran Pengunjung
5 |
6 | Pengunjung situs Kawal Pemilu dikelompokkan menjadi tiga jenis peran:
7 |
8 |
9 |
10 | Publik adalah pengunjung laman utama
11 | kawalpemilu.org yang tidak melakukan
12 | login via Facebook. Pengunjung publik hanya bisa melihat hasil tabulasi tapi
13 | tidak bisa meng-upload foto C1 plano. Kunjungi
14 | upload.kawalpemilu.org untuk meng-upload
15 | foto C1 plano dari TPS kamu.
16 |
17 |
18 | Relawan adalah pengunjung yang sudah terdaftar di situs
19 | upload.kawalpemilu.org dan dapat melakukan
20 | hal berikut:
21 |
22 |
23 |
24 | Meng-upload foto C1 plano dari TPS atau kantor kelurahan/desa.
25 | Relawan dapat meng-upload banyak foto per TPS dan boleh mengawal lebih
26 | dari satu TPS.
27 |
28 |
29 | Melaporkan masalah atau kesalahan pada foto yang terlihat.
30 |
31 |
32 |
33 |
34 | Moderator . Selain dapat melakukan semua peran sebagai Relawan, seorang
35 | Moderator juga dapat:
36 |
37 |
38 |
39 | Menerima atau menolak foto yang di-upload oleh Relawan berdasarkan
40 | kriteria-kriteria yang meliputi kualitas foto dan apakah syarat-syarat
41 | keaslian foto tersebut terpenuhi.
42 |
43 |
44 | Memasukkan angka (digitisasi) foto yang di-upload .
45 |
46 |
47 |
48 |
49 |
50 | {{/inline}}
51 | {{/_layout/static}}
--------------------------------------------------------------------------------
/src/tabulasi/types.ts:
--------------------------------------------------------------------------------
1 | export const enum SUM_KEY {
2 | jum = "jum",
3 | pas1 = "pas1",
4 | pas2 = "pas2",
5 | sah = "sah",
6 | tSah = "tSah",
7 | cakupan = "cakupan",
8 | pending = "pending",
9 | error = "error",
10 | janggal = "janggal",
11 | pkb = "pkb",
12 | ger = "ger",
13 | pdi = "pdi",
14 | gol = "gol",
15 | nas = "nas",
16 | gar = "gar",
17 | ber = "ber",
18 | sej = "sej",
19 | per = "per",
20 | ppp = "ppp",
21 | psi = "psi",
22 | pan = "pan",
23 | han = "han",
24 | dem = "dem",
25 | pa = "pa",
26 | ps = "ps",
27 | pda = "pda",
28 | pna = "pna",
29 | pbb = "pbb",
30 | pkp = "pkp",
31 | pJum = "pJum",
32 | pSah = "pSah",
33 | pTSah = "pTSah",
34 | laporKpu = "laporKpu"
35 | }
36 |
37 | export const enum FORM_TYPE {
38 | // Full blown until digitized.
39 | PPWP = 1,
40 | DPR,
41 |
42 | // Only up to halaman, not digitized.
43 | DPD,
44 | DPRP,
45 | DPRPB,
46 | DPRA,
47 | DPRD_PROV,
48 | DPRD_KAB_KOTA,
49 | DPRK,
50 |
51 | // Up to choosing this type.
52 | OTHERS,
53 | PEMANDANGAN,
54 | MALICIOUS
55 | }
56 |
57 |
58 | export declare type SumMap = {
59 | [key in SUM_KEY]: number;
60 | };
61 |
62 | export const enum IS_PLANO {
63 | YES = 1,
64 | NO = 2
65 | }
66 |
67 | export declare type Halaman = '0' | '1' | '2' | '2.1' | '2.2' | '2.3' | '2.4' | '2.5' | '2.6' | '2.7' | '2.8' | '2.9' | '2.10' | '2.11' | '2.12' | '2.13' | '2.14' | '2.15' | '2.16' | '2.17' | '2.18' | '2.19' | '2.20' | '3';
68 |
69 | export interface C1Form {
70 | type: FORM_TYPE;
71 | plano: IS_PLANO;
72 | halaman: Halaman;
73 | }
74 |
75 | export interface Aggregate {
76 | sum: SumMap;
77 | ts: number;
78 | c1: C1Form;
79 | }
80 |
81 | export interface TpsAggregate extends Aggregate {
82 | photos: {
83 | [url: string]: Aggregate;
84 | };
85 | }
86 |
87 | export interface HierarchyNode {
88 | id: number;
89 | name: string;
90 | parentIds: number[];
91 | parentNames: string[];
92 | children: any[];
93 | depth: number;
94 | data: {
95 | [cid: string]: TpsAggregate;
96 | };
97 | kpu: KpuData;
98 | }
99 |
100 | export declare type KpuData = {
101 | [cid: string]: SumMap;
102 | };
103 |
--------------------------------------------------------------------------------
/src/tabulasi/agg-pileg.ts:
--------------------------------------------------------------------------------
1 | import { ScreenSize } from "./screen";
2 | import { HierarchyNode } from "./types";
3 | import { PageParam, getSumValue } from "./common";
4 |
5 | interface PartaiEntry {
6 | label: string
7 | field: string
8 | }
9 |
10 | export const PartaiEntries: PartaiEntry[] = [
11 | { label: 'PKB', field: 'pkb' },
12 | { label: 'Gerindra', field: 'ger' },
13 | { label: 'PDI', field: 'pdi' },
14 | { label: 'Golkar', field: 'gol' },
15 | { label: 'Nasdem', field: 'nas' },
16 | { label: 'Garuda', field: 'gar' },
17 | { label: 'Berkarya', field: 'ber' },
18 | { label: 'PKS', field: 'sej' },
19 | { label: 'Perindo', field: 'per' },
20 | { label: 'PPP', field: 'ppp' },
21 | { label: 'PSI', field: 'psi' },
22 | { label: 'PAN', field: 'pan' },
23 | { label: 'Hanura', field: 'han' },
24 | { label: 'Demokrat', field: 'dem' },
25 | { label: 'PA', field: 'pa' },
26 | { label: 'PS', field: 'ps' },
27 | { label: 'PDA', field: 'pda' },
28 | { label: 'PNA', field: 'pna' },
29 | { label: 'PBB', field: 'pbb' },
30 | { label: 'PKP', field: 'pkp' },
31 | ]
32 |
33 | export class AggPilegRenderer {
34 | constructor(screenSize: ScreenSize) {
35 | }
36 |
37 | render(param: PageParam, node: HierarchyNode): string {
38 | var s = ''
39 | s += ''
40 |
41 | // header
42 | s += ''
50 |
51 | // rows
52 | for (var i = 0; i < node.children.length; i++) {
53 | let ch = node.children[i]
54 | let id = ch[0]
55 | let data = node.data[id]
56 | let sum = data.sum
57 | let url = '#' + param.type + ':' + id
58 |
59 | let S = (key: string) => getSumValue(sum, key)
60 | let F = (n: number) => n.toLocaleString('id')
61 | let FS = (key: string) => F(S(key))
62 |
63 | let name = ch[1]
64 | let ntps = ch[2]
65 |
66 | s += ''
67 | s += `${i + 1} `
68 | s += `${name} `
69 | PartaiEntries.forEach((e) => {
70 | s += `${FS(e.field)} `
71 | })
72 | s += `${F(ntps)} `
73 | s += ' '
74 | }
75 |
76 |
77 | s += '
'
78 | return s
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/tabulasi/agg.ts:
--------------------------------------------------------------------------------
1 | import { PageParam } from "./common";
2 | import { HierarchyNode } from "./types";
3 | import { ScreenSize } from "./screen";
4 | import { AggPilpresRenderer } from "./agg-pilpres";
5 | import { AggPilegRenderer } from "./agg-pileg";
6 | import { updateStickyTableHeader, updateStickyTableColumn, updateStickyTableCorner, updateStickyTableFooter, updateStickyTableTotal } from "./sticky";
7 |
8 | import { setupSort } from "./sort";
9 |
10 | export class AggRenderer {
11 | private pilpres: AggPilpresRenderer
12 | private pileg: AggPilegRenderer
13 |
14 | constructor(
15 | private screenSize: ScreenSize,
16 | private target: HTMLElement) {
17 |
18 | this.pilpres = new AggPilpresRenderer(screenSize)
19 | this.pileg = new AggPilegRenderer(screenSize)
20 | }
21 |
22 | render(param: PageParam, node: HierarchyNode) {
23 | this.target.innerHTML = this._render(param, node)
24 | this.target.classList.add(param.type)
25 | setupSort(this.target)
26 | }
27 |
28 | private _render(param: PageParam, node: HierarchyNode) {
29 | if (node.depth >= 4)
30 | return ''
31 |
32 | if (param.type == 'pilpres') {
33 | if (this.screenSize.is('desktop') || this.screenSize.is('tablet'))
34 | return this.pilpres.render(param, node, 'full')
35 | else
36 | return this.pilpres.render(param, node, 'compact')
37 | }
38 | else if (param.type == 'pileg') {
39 | return this.pileg.render(param, node)
40 | }
41 |
42 | return ''
43 | }
44 | }
45 |
46 | function attachStickyListener(fn: () => any) {
47 | var agg = document.getElementById('agg')
48 |
49 | window.addEventListener('scroll', fn)
50 | agg.addEventListener('scroll', fn)
51 |
52 | var isUpdatingTable = (mutations: MutationRecord[]) => {
53 | var mm = mutations
54 | .filter((m) => m.type == 'childList')
55 | .filter((m) => m.addedNodes.length > 0)
56 | .filter((m) => {
57 | let result = false
58 | m.addedNodes.forEach((n: HTMLElement) => {
59 | if (n.tagName == 'TABLE' && n.classList.contains('table'))
60 | result = true
61 | })
62 | return result
63 | })
64 | return mm.length > 0
65 | }
66 |
67 | var observer = new MutationObserver((mutations) => {
68 | if (isUpdatingTable(mutations))
69 | fn()
70 | })
71 | observer.observe(agg, { childList: true })
72 |
73 | }
74 | attachStickyListener(updateStickyTableHeader)
75 | attachStickyListener(updateStickyTableColumn)
76 | attachStickyListener(updateStickyTableCorner)
77 | attachStickyListener(updateStickyTableFooter)
78 | attachStickyListener(updateStickyTableTotal)
79 |
--------------------------------------------------------------------------------
/src/tabulasi/agg-pilpres-common.ts:
--------------------------------------------------------------------------------
1 | import { HierarchyNode } from "./types";
2 |
3 | export function round100(n: number): number {
4 | return Math.round(n * 10000) / 100
5 | }
6 |
7 | function N(n: number | undefined | null): number {
8 | return n || 0
9 | }
10 |
11 | export class Entry {
12 | pas1: number = 0
13 | pas2: number = 0
14 | sah: number = 0
15 | tSah: number = 0
16 | ntps: number = 0
17 | pending: number = 0
18 | cakupan: number = 0
19 | error: number = 0
20 |
21 | pas1kpu: number = 0
22 | pas2kpu: number = 0
23 | sahKpu: number = 0
24 | tSahKpu: number = 0
25 |
26 | static newFromNode(node: HierarchyNode, idx: number): Entry {
27 | var ch = node.children[idx]
28 | var id = ch[0]
29 | var data = node.data[id]
30 | var sum = data.sum
31 | var entry = new Entry()
32 | entry.pas1 = N(sum.pas1)
33 | entry.pas2 = N(sum.pas2)
34 | entry.sah = N(sum.sah)
35 | entry.tSah = N(sum.tSah)
36 | entry.pending = N(sum.pending)
37 | entry.cakupan = N(sum.cakupan)
38 | entry.ntps = ch[2] as number
39 | entry.error = N(sum.error)
40 |
41 | if (node.kpu && node.kpu[id]) {
42 | var kpu = node.kpu[id]
43 | entry.pas1kpu = N(kpu.pas1) || 0
44 | entry.pas2kpu = N(kpu.pas2) || 0
45 | entry.sahKpu = N(kpu.sah) || 0
46 | entry.tSahKpu = N(kpu.tSah) || 0
47 | }
48 | return entry
49 | }
50 |
51 | plus(entry: Entry): Entry {
52 | var result = new Entry()
53 | result.pas1 = this.pas1 + entry.pas1
54 | result.pas2 = this.pas2 + entry.pas2
55 | result.sah = this.sah + entry.sah
56 | result.tSah = this.tSah + entry.tSah
57 | result.ntps = this.ntps + entry.ntps
58 | result.pending = this.pending + entry.pending
59 | result.cakupan = this.cakupan + entry.cakupan
60 | result.error = this.error + entry.error
61 |
62 | result.pas1kpu = this.pas1kpu + entry.pas1kpu
63 | result.pas2kpu = this.pas2kpu + entry.pas2kpu
64 | result.sahKpu = this.sahKpu + entry.sahKpu
65 | result.tSahKpu = this.tSahKpu + entry.tSahKpu
66 |
67 | return result
68 | }
69 |
70 | get tpsEstimasi(): number {
71 | return this.cakupan - this.pending
72 | }
73 |
74 | get tpsEstimasiRatio(): number {
75 | return this.tpsEstimasi / this.ntps
76 | }
77 |
78 | get pas1Ratio100(): number {
79 | return round100(this.pas1 / (this.pas1 + this.pas2))
80 | }
81 |
82 | get pas2Ratio100(): number {
83 | return 100 - this.pas1Ratio100
84 | }
85 |
86 | get pas1KpuRatio100(): number {
87 | return round100(this.pas1kpu / (this.pas1kpu + this.pas2kpu))
88 | }
89 |
90 | get pas2KpuRatio100(): number {
91 | return 100 - this.pas1KpuRatio100
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/_stylesheets/_content.scss:
--------------------------------------------------------------------------------
1 | main {
2 | height: auto;
3 |
4 | @media (min-width: 780px) {
5 | box-shadow: 0 12px 36px rgba(0, 0, 0, 0.2);
6 | }
7 |
8 | .skew {
9 | background-color: #fff;
10 | height: 60px;
11 | overflow: hidden;
12 | position: relative;
13 |
14 | @media #{$phone} {
15 | height: 25px;
16 | }
17 |
18 | &:before {
19 | background-color: #3a1c71;
20 | background-image: url(/assets/overlay.svg), linear-gradient(45deg, #3a1c71 0%, #d76d77 35%, #ffaf7b 100%);
21 | background-size: cover;
22 | content: "";
23 | display: block;
24 | height: 60px;
25 | position: absolute;
26 | transform-origin: 0;
27 | transform: skewY(-3deg);
28 | -webkit-backface-visibility: hidden;
29 | bottom: 0;
30 | left: 0;
31 | width: 100%;
32 | z-index: 1;
33 | }
34 | }
35 |
36 | article {
37 | background-color: #fff;
38 | border-bottom: 1px solid #c4cdd5;
39 | flex: 1;
40 | padding: 25px 0 40px 0;
41 |
42 | > * {
43 | margin-left: 25px;
44 | margin-right: 25px;
45 | }
46 |
47 | @media #{$phone} {
48 | padding: 5px 0 10px 0;
49 |
50 | > * {
51 | margin-left: 10px;
52 | margin-right: 10px;
53 |
54 | &.table {
55 | margin-left: 0;
56 | margin-right: 0;
57 | }
58 | }
59 | }
60 | @media (min-width: 780px) {
61 | border-bottom: none;
62 | }
63 | }
64 |
65 | h1 {
66 | margin-top: 10px;
67 | border-bottom: 1px solid #333;
68 | line-height: 38px;
69 | margin-bottom: 32px;
70 |
71 | @media #{$phone} {
72 | margin-top: 0;
73 | margin-bottom: 15px;
74 | }
75 | @media #{$phone-wide} {
76 | font-size: 18px;
77 | }
78 | @media #{$phone-small} {
79 | font-size: 15px;
80 | }
81 | }
82 |
83 | p {
84 | margin-bottom: 28px;
85 |
86 | @media #{$phone} {
87 | margin-top: 0px;
88 | margin-bottom: 10px;
89 | font-size: 14px;
90 | line-height: 26px;
91 | }
92 | }
93 |
94 | &.static {
95 | line-height: 30px;
96 |
97 | &.faq {
98 | h2 {
99 | margin-bottom: 0px;
100 | }
101 | ul {
102 | padding-left: 0;
103 | li {
104 | list-style: none;
105 |
106 | > p {
107 | margin: 0;
108 | }
109 | > p:last-child {
110 | margin-bottom: 20px;
111 | }
112 | }
113 | }
114 | }
115 |
116 | &.roles {
117 | > ol > li {
118 | list-style: none;
119 |
120 | ul {
121 | padding-left: 20px;
122 | }
123 | }
124 | }
125 |
126 | &.privasi {
127 | > ol > li {
128 | margin-left: 20px;
129 | }
130 | }
131 |
132 | &.kontak {
133 | li {
134 | list-style: none;
135 | }
136 | }
137 | }
138 | }
139 |
140 | @media #{$phone} {
141 | main > .table {
142 | padding-left: 0px;
143 | padding-right: 0px;
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/src/disclaimer.hbs:
--------------------------------------------------------------------------------
1 | {{#> _layout/static title="Disclaimer" name="disclaimer" }}
2 | {{#*inline "content"}}
3 |
4 | Disclaimer
5 |
6 | KawalPemilu.org merupakan situs yang digagas oleh
7 | relawan pro data yang menjunjung tinggi netralitas dan bukanlah merupakan situs
8 | dari kandidat manapun di Pemilu. Pilihan politik dari relawan merupakan hak
9 | individu dan adalah tanggung jawabnya masing-masing.
10 |
11 | Pengumpulan dan penginputan data pada situs ini dilakukan dengan cara
12 | crowd sourcing atau gotong royong. Para relawannya merupakan entitas individu
13 | yang berlokasi di berbagai belahan dunia dan bekerja sama secara virtual.
14 |
15 | Pada Pemilu 2019, KawalPemilu.org merupakan mitra dari
16 | Netgrit (Network for
17 | Democracy and Electoral Integrity) dalam gerakan KawalPemilu - Jaga Suara
18 | 2019 . Kemitraan ini resmi terakreditasi oleh Bawalsu RI . Selain dari
20 | kemitraan ini, situs KawalPemilu.org tidak terafiliasi dengan pihak manapun.
21 |
22 | Foto yang digunakan relawan untuk tes upload foto pada situs ini sebelum Pemilu
23 | berlangsung merupakan milik relawan dan bisa saja memiliki hak cipta. Hak cipta
24 | foto tes tersebut sepenuhnya tanggung jawab peng-upload . Seluruh foto tes
25 | akan dihapus sepenuhnya dari situs pada tanggal 16 April 2019.
26 |
27 | Pada saat pemilu berlangsung, foto yang masuk ke situs KawalPemilu.org adalah
28 | foto formulir C1 plano atau C1 salinan. Data dan foto formulir yang digunakan
29 | merupakan dokumen negara Republik Indonesia yang bersifat publik dan tunduk
30 | pada pasal 43 huruf b Undang-Undang Nomor 28 Tahun 2014 tentang Hak Cipta. Data
31 | tersebut merupakan data otentik yang ditampilkan pada halaman tabulasi situs
32 | KawalPemilu.org. Penggunaan kembali data tersebut oleh pengunjung situs, tunduk
33 | dan taat pada aturan hukum yang berlaku.
34 |
35 | Semua informasi di situs KawalPemilu.org dipublikasikan dengan itikad baik dan
36 | hanya untuk tujuan informasi bagi publik dan adalah bukan merupakan hasil resmi
37 | dari penyelenggara Pemilu. KawalPemilu.org tidak memberikan jaminan tentang
38 | kelengkapan, keandalan, dan keakuratan data yang ada. Namun demikian kami akan
39 | tetap berupaya semaksimal mungkin mengecek akurasi foto-foto yang masuk dan
40 | data yang telah diverifikasi dengan cermat. Publik akan dapat melihat foto-foto
41 | yang telah divalidasi dan mencocokkan datanya dengan bukti foto yang mereka
42 | ambil di TPS masing-masing untuk verifikasi ulang.
43 |
44 | Segala tindakan yang diambil atas informasi yang Anda temukan pada situs ini,
45 | sepenuhnya merupakan tanggung jawab Anda selaku individu. KawalPemilu.org tidak
46 | bertanggung jawab atas kerugian dan/atau kerusakan yang mungkin timbul
47 | sehubungan dengan penggunaan data yang telah dan akan diambil dari situs ini.
48 |
49 |
52 | {{/inline}}
53 | {{/_layout/static}}
--------------------------------------------------------------------------------
/webpack.common.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const HtmlWebpackPlugin = require('html-webpack-plugin')
4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin')
5 | const CleanWebpackPlugin = require('clean-webpack-plugin')
6 | const CopyPlugin = require('copy-webpack-plugin')
7 | const TerserJSPlugin = require('terser-webpack-plugin')
8 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
9 |
10 | function staticPage(name) {
11 | return new HtmlWebpackPlugin({
12 | filename: name + '/index.html',
13 | template: 'src/' + name + '.hbs',
14 | chunks: ['main', 'static'],
15 | })
16 | }
17 |
18 | module.exports = {
19 | devServer: {
20 | contentBase: path.join(__dirname, 'dist'),
21 | compress: true,
22 | port: 9000
23 | },
24 | entry: {
25 | main: './src/main.js',
26 | tabulasi: './src/tabulasi/tabulasi.ts',
27 | static: './src/static.js',
28 | },
29 | output: {
30 | filename: '[name].[contenthash].js',
31 | path: path.resolve(__dirname, 'dist')
32 | },
33 | plugins: [
34 | new CleanWebpackPlugin(),
35 | new CopyPlugin([
36 | { from: 'src/assets', to: 'assets' },
37 | { from: 'src/404.html', to: '404.html' },
38 | { from: 'src/index2.html', to: 'index2.html' },
39 | ]),
40 | new MiniCssExtractPlugin({
41 | filename: '[name].[contenthash].css',
42 | chunkFilename: '[id].[contenthash].css',
43 | }),
44 | new HtmlWebpackPlugin({
45 | filename: 'index.html',
46 | template: 'src/index.hbs',
47 | chunks: ['main', 'tabulasi'],
48 | }),
49 | staticPage('disclaimer'),
50 | staticPage('kontak'),
51 | staticPage('faq'),
52 | staticPage('privasi'),
53 | staticPage('jenis-peran-pengunjung'),
54 | staticPage('tentang'),
55 | staticPage('visualisasi'),
56 | ],
57 | module: {
58 | rules: [
59 | {
60 | test: /\.hbs$/,
61 | use: [
62 | {
63 | loader: 'handlebars-loader',
64 | options: {
65 | helperDirs: path.join(__dirname, 'src/helpers'),
66 | precompileOptions: {
67 | knownHelpersOnly: false
68 | }
69 | }
70 | }
71 | ]
72 | },
73 | {
74 | test: /\.scss$/,
75 | use: [
76 | MiniCssExtractPlugin.loader,
77 | "css-loader",
78 | "postcss-loader",
79 | "sass-loader"
80 | ]
81 | },
82 | {
83 | test: /\.tsx?$/,
84 | use: [
85 | {
86 | loader: 'ts-loader',
87 | options: {
88 | transpileOnly: true,
89 | experimentalWatchApi: true,
90 | },
91 | }
92 | ],
93 | exclude: /node_modules/,
94 | }
95 | ]
96 | },
97 | resolve: {
98 | extensions: ['.tsx', '.ts', '.js']
99 | },
100 | optimization: {
101 | minimizer: [new TerserJSPlugin({}), new OptimizeCSSAssetsPlugin({})],
102 | },
103 | };
104 |
--------------------------------------------------------------------------------
/src/tabulasi/tabulasi.ts:
--------------------------------------------------------------------------------
1 | import './tabulasi.scss'
2 | import './agg.scss'
3 | import './nav.scss'
4 | import './tps.scss'
5 |
6 | import { PageParam, PageTypes } from './common'
7 | import { HierarchyNode, FORM_TYPE } from './types'
8 | import { PageRenderer } from './page'
9 | import { debounce } from 'debounce'
10 | import { ScreenSize } from './screen'
11 |
12 | declare function ga(...args: any[]): any
13 |
14 | const screenSize = new ScreenSize()
15 |
16 | function getPageParam(): PageParam {
17 | var h = document.location.hash;
18 |
19 | var type = 'pilpres';
20 | var id = 0;
21 | var pc = h.indexOf(':')
22 | var ps = h.indexOf('/')
23 | var tps: number | null = null
24 | if (h && h.length) {
25 | if (pc >= 0) {
26 | type = h.substring(1, pc)
27 | id = Number(h.substring(pc + 1, ps < 0 ? h.length : ps))
28 | } else {
29 | id = Number(h.substring(1, ps < 0 ? h.length : ps))
30 | }
31 | }
32 | if (ps >= 0) {
33 | tps = Number(h.substring(ps + 1))
34 | }
35 |
36 | if (PageTypes.indexOf(type) < 0)
37 | type = 'pilpres'
38 | var form = type == 'pileg' ? FORM_TYPE.DPR : FORM_TYPE.PPWP
39 |
40 | var photos: FORM_TYPE[] = [form]
41 | if (form == FORM_TYPE.PPWP)
42 | photos.push(FORM_TYPE.PEMANDANGAN)
43 |
44 | return { type, form, id, tps, photos }
45 | }
46 |
47 | function updatePageHash(param: PageParam) {
48 | var h = '#' + param.type + ':' + param.id
49 | history.replaceState({}, 'Kawal Pemilu - Jaga Suara 2019', location.pathname + h)
50 | }
51 |
52 |
53 | function xhr(url: string, cb: (txt: string) => void) {
54 | var oReq = new XMLHttpRequest();
55 | oReq.addEventListener("load", function () {
56 | cb(this.responseText);
57 | });
58 | oReq.open("GET", url);
59 | oReq.send();
60 | }
61 |
62 | function get(id: number, cb: (node: HierarchyNode) => void) {
63 | var ts = new Date().getTime()
64 | var url = 'https://kawal-c1.appspot.com/api/c/' + id + '?' + ts
65 | xhr(url + id + '?' + new Date().getTime(), function (res) {
66 | var duration = new Date().getTime() - ts
67 | ga('send', 'timing', 'kp-data', 'load', duration)
68 | cb(JSON.parse(res) as HierarchyNode);
69 | });
70 | }
71 |
72 | function load() {
73 | var param = getPageParam()
74 | updatePageHash(param)
75 |
76 | var renderer = new PageRenderer(
77 | screenSize,
78 | document.getElementById('navigasi'),
79 | document.getElementById('agg'),
80 | document.getElementById('tps')
81 | )
82 | get(param.id, (node) => {
83 | ga('send', 'pageview', {
84 | dimension1: node.id,
85 | dimension2: node.depth,
86 | dimension3: param.type,
87 | })
88 | renderer.render(param, node)
89 | })
90 | }
91 |
92 | function updateScreenSize() {
93 | function C(selector: string): boolean {
94 | return window.matchMedia(selector).matches
95 | }
96 | // check sizes.scss
97 | screenSize.update({
98 | phoneSmall: C("only screen and (max-width: 370px)"),
99 | phoneWide: C("only screen and (min-width: 371px) and (max-width: 620px)"),
100 | phone: C("only screen and (max-width: 620px)"),
101 | tablet: C("only screen and (min-width: 621px) and (max-width: 1000px)"),
102 | mobile: C("only screen and (max-width: 1000px)"),
103 | desktop: C("only screen and (min-width: 1001px)"),
104 | })
105 | }
106 |
107 | window.onload = load
108 | window.onhashchange = load
109 |
110 | window.addEventListener('resize', debounce(() => {
111 | updateScreenSize()
112 | load()
113 | }, 500))
114 | updateScreenSize()
--------------------------------------------------------------------------------
/src/tabulasi/agg-pilpres.ts:
--------------------------------------------------------------------------------
1 | import { PageParam, getSumValue } from "./common";
2 | import { HierarchyNode, TpsAggregate } from "./types";
3 | import { ScreenSize } from "./screen";
4 | import { Entry } from "./agg-pilpres-common";
5 | import {
6 | PasKpKpuFormatter,
7 | SahFormatter,
8 | EstimasiFormatter,
9 | TidakSahFormatter,
10 | TpsCakupanFormatter,
11 | EstimasiFull2Formatter,
12 | SahKpKpuFormatter,
13 | TidakSahKpKpuFormatter
14 | } from "./agg-pilpres-formatter";
15 |
16 | export declare type Mode = 'compact' | 'full'
17 |
18 | export class AggPilpresRenderer {
19 | constructor(private screenSize: ScreenSize) {
20 | }
21 |
22 | render(param: PageParam, node: HierarchyNode, mode: Mode): string {
23 | var s = ''
24 | s += ''
25 |
26 | s += ''
51 |
52 | let F = (n: number) => n.toLocaleString('id')
53 |
54 | let pas1Fmt = PasKpKpuFormatter.newForPas1()
55 | let pas2Fmt = PasKpKpuFormatter.newForPas2()
56 | let estFmt = new EstimasiFormatter()
57 | let sahFmt = new SahKpKpuFormatter()
58 | let tSahFmt = new TidakSahKpKpuFormatter()
59 | let estFullFmt = new EstimasiFull2Formatter()
60 | let tpsCakupanFmt = new TpsCakupanFormatter()
61 |
62 | let total = new Entry()
63 | for (var i = 0; i < node.children.length; i++) {
64 | let entry = Entry.newFromNode(node, i)
65 | total = total.plus(entry)
66 |
67 | let ch = node.children[i]
68 | let id = ch[0]
69 | let url = '#' + param.type + ':' + id
70 |
71 | let name = ch[1]
72 |
73 | s += ''
74 | s += `${i + 1} `
75 | s += `${name} `
76 |
77 | s += pas1Fmt.format(entry)
78 | s += pas2Fmt.format(entry)
79 |
80 | if (mode == 'compact')
81 | s += estFmt.format(entry)
82 |
83 | if (mode == 'full') {
84 | s += sahFmt.format(entry)
85 | s += tSahFmt.format(entry)
86 | // s += tpsKpuFmt.format(entry)
87 | // s += tpsCakupanFmt.format(entry)
88 | s += estFullFmt.format(entry)
89 | // s += tpsPendingFmt.format(entry)
90 | // s += tpsErrorFmt.format(entry)
91 | }
92 | s += ' '
93 | }
94 |
95 | // total
96 | s += ''
116 |
117 | s += '
'
118 |
119 | return s
120 | }
121 | }
--------------------------------------------------------------------------------
/src/_stylesheets/_header.scss:
--------------------------------------------------------------------------------
1 | .global-header {
2 | display: flex;
3 | box-sizing: border-box;
4 | flex-direction: row;
5 | justify-content: space-between;
6 | position: relative;
7 |
8 | .logo {
9 | align-items: center;
10 | color: #1a222b;
11 | display: flex;
12 | font-family: "Anton", sans-serif;
13 | font-style: italic;
14 | text-decoration: none;
15 | @media #{$phone} {
16 | padding: 0 2px;
17 | }
18 |
19 | &:hover img.logo {
20 | animation: rotate-in-center 1s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
21 | }
22 | }
23 |
24 | .site-title {
25 | margin-left: 10px;
26 | white-space: nowrap;
27 | }
28 |
29 | h1 {
30 | color: #333;
31 | line-height: 28px;
32 | }
33 |
34 | .tagline {
35 | color: #5c6ac4;
36 | }
37 |
38 | .menu-btn {
39 | display: none;
40 |
41 | &:checked ~ .main-menu {
42 | max-height: 340px;
43 | padding-top: 32px;
44 |
45 | ul {
46 | border-top: 1px solid #333;
47 | }
48 |
49 | @media #{$desktop} {
50 | max-height: 120px;
51 | padding-top: 0;
52 | ul {
53 | border-top: 1px solid #333;
54 | }
55 | }
56 | }
57 |
58 | &:checked ~ .menu-icon .navicon {
59 | background: transparent;
60 | }
61 |
62 | &:checked ~ .menu-icon .navicon:before {
63 | transform: rotate(-45deg);
64 | }
65 |
66 | &:checked ~ .menu-icon .navicon:after {
67 | transform: rotate(45deg);
68 | }
69 |
70 | &:checked ~ .menu-icon .navicon:before,
71 | &:checked ~ .menu-icon .navicon:after {
72 | top: 0;
73 | }
74 | }
75 |
76 | .menu-icon {
77 | cursor: pointer;
78 | display: inline-block;
79 | margin: 48px 0;
80 | padding: 10px;
81 | position: relative;
82 | user-select: none;
83 | z-index: 3;
84 |
85 | @media #{$desktop} {
86 | display: none;
87 | }
88 | @media #{$phone} {
89 | margin: 24px 0;
90 | }
91 |
92 | .navicon {
93 | background: #333;
94 | display: block;
95 | height: 2px;
96 | position: relative;
97 | transition: background 0.2s ease-out;
98 | width: 18px;
99 |
100 | &:before,
101 | &:after {
102 | background: #333;
103 | content: "";
104 | display: block;
105 | height: 100%;
106 | position: absolute;
107 | transition: all 0.2s ease-out;
108 | width: 100%;
109 | }
110 |
111 | &:before {
112 | top: 5px;
113 | }
114 | &:after {
115 | top: -5px;
116 | }
117 | }
118 | }
119 |
120 | .main-menu {
121 | background: #fff;
122 | box-shadow: 0 12px 36px rgba(0, 0, 0, 0.2);
123 | flex: none;
124 | max-height: 0;
125 | overflow: hidden;
126 | position: absolute;
127 | top: 42px;
128 | right: 0;
129 | transition: max-height 0.2s ease-out;
130 | width: 100%;
131 | z-index: 2;
132 |
133 | @media #{$desktop} {
134 | background: transparent;
135 | box-shadow: none;
136 | clear: right;
137 | flex: 1 0 auto;
138 | max-height: 120px;
139 | padding-top: 0;
140 | position: relative;
141 | top: auto;
142 | right: auto;
143 | width: auto;
144 | }
145 | }
146 |
147 | ul {
148 | display: block;
149 | margin: 0;
150 | overflow: hidden;
151 | text-transform: uppercase;
152 |
153 | padding: 0;
154 | @media #{$desktop} {
155 | padding: 0 0 48px 0;
156 |
157 | align-items: center;
158 | border-top: none;
159 | display: flex;
160 | flex-direction: row;
161 | float: right;
162 | }
163 |
164 | li {
165 | flex: 0 0 auto;
166 | list-style-type: none;
167 |
168 | a {
169 | color: #666;
170 | display: block;
171 | font-size: 14px;
172 | font-weight: 700;
173 | line-height: 1;
174 | position: relative;
175 | text-decoration: none;
176 |
177 | padding: 24px 20px 24px;
178 | @media #{$desktop} {
179 | padding: 48px 20px 0;
180 | }
181 |
182 | &:hover {
183 | color: #333;
184 | text-decoration: none;
185 | }
186 | }
187 |
188 | &.active {
189 | a {
190 | color: #333;
191 |
192 | &:before {
193 | border-left: 6px solid #5c6ac4;
194 | content: "";
195 | height: calc(100% - 48px);
196 | left: 0;
197 | position: absolute;
198 | right: auto;
199 | top: auto;
200 | width: calc(100% - 32px);
201 |
202 | @media #{$desktop} {
203 | animation: main-menu-slide-bottom 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
204 | border-top: 6px solid #5c6ac4;
205 | height: auto;
206 | left: auto;
207 | top: 0;
208 | }
209 | }
210 | }
211 | }
212 | }
213 | }
214 | }
215 |
--------------------------------------------------------------------------------
/src/faq.hbs:
--------------------------------------------------------------------------------
1 | {{#> _layout/static title="Pertanyaan Umum" name="faq" }}
2 | {{#*inline "content"}}
3 |
4 | Pertanyaan Umum
5 |
6 |
7 |
8 |
9 |
10 | Apakah saya hanya boleh mengawal TPS dimana saya nyoblos?
11 |
12 | Nggak juga. Misalnya di rumah ada 5 orang yang nyoblos di TPS yang sama, bisa
13 | disebar untuk ambil foto dan upload di 5 TPS yang berbeda. Tapi setidaknya
14 | bisa #PantauFotoUpload di TPS domisili.
15 |
16 |
17 | Bolehkah saya hanya upload foto C1 Pilpres?
18 |
19 | Boleh. Mau Pilpres (istilahnya di formulir: PPWP) saja boleh, mau Pilpres DPR
20 | DPRD sampai DPD pun boleh.
21 |
22 |
23 | Bolehkah saya posting foto C1-nya di Facebook / WA / Twitter saya saja?
24 |
25 | Silakan kalau mau posting di medsos masing-masing dengan hestek
26 | #PantauFotoUpload. Tapi kalau kamu upload fotonya ke situs
27 | upload.kawalpemilu.org, kamu sudah mengawal TPS kamu satu langkah lebih maju
28 | karena angka di foto itu dihitung sampai tingkat nasional sebagai pembanding
29 | data KPU.
30 |
31 |
32 | Bolehkah saya posting foto C1 PLANO yang belum ditandatangan?
33 |
34 | Kalau kamu yakin penghitungan sudah selesai, silakan kalau mau foto dan
35 | upload walaupun belum ditandatangan. Tapi pastikan nomor TPS dan
36 | kelurahan/kecamatan/kabupatennya sudah ditulis dan angka-angkanya sudah
37 | dijumlahkan.
38 |
39 |
40 | Bolehkah saya upload foto yang diambil oleh orang lain?
41 |
42 | Silakan, terutama kalau teman kamu tidak punya Facebook login. Bisa kumpulkan
43 | fotonya di kamu lalu kamu upload satu persatu.
44 |
45 |
46 | Kok perlu fotonya banyak banget?
47 |
48 | Karena Pemilu kita kali ini serentak. Karenanya ada 2 lembar C1 PLANO untuk
49 | Pilpres (atau PPWP) dan 18 lembar untuk DPR RI.
50 |
51 |
52 | Bolehkah saya upload foto catatan hitungan saya sendiri?
53 |
54 | Catatan sendiri sulit dibuktikan keasliannya. Karenanya kami mengajak warga
55 | untuk mengambil foto formulir C1, terutama plano, yang sulit dipalsukan.
56 |
57 |
58 |
59 | Tentang kesulitan yang mungkin dihadapi
60 |
61 |
62 |
63 | Kalau saya kesulitan upload ke situs di hari H, kemana saya mesti melapor?
64 |
65 | Bisa kirim fotonya via e-mail ke kawalpemilu2019@gmail.com ,
67 | kirim direct
68 | message di Facebook fanpage kami https://m.me/kawalpemilu.org ,
69 | atau Twitter
70 | @KawalPemilu2019 .
71 |
72 | Posting fotonya di timeline Facebook atau Twitter kamu dengan hestek
73 | #PantauFotoUpload. Pastikan privacy setting di medsos kamu "public".
74 |
75 |
76 | Kalau saya kehabisan waktu untuk mengambil foto C1 PLANO bagaimana?
77 |
78 | Keesokan harinya, kamu bisa mengambil fotonya di Kantor Lurah / Desa. Salinan
79 | C1 dari semua TPS yang ada di kelurahan tersebut harus sudah ditempelkan
80 | untuk dilihat warga.
81 |
82 |
83 | Penghitungan suaranya lama sekali, saya ngantuk ...
84 |
85 | Memang diperkirakan berlangsung sampai malam. Kalau tidak bisa menunggu, bisa
86 | datang ke Kantor Lurah / Desa keesokan harinya untuk mengambil foto salinan
87 | formulir C1.
88 |
89 |
90 |
91 | Tentang Facebook
92 |
93 |
94 |
95 | Wah, saya lupa password FB! Gimana dong?
96 |
97 | Coba ganti password Anda.
98 |
99 |
100 | Bagaimana kalau saya nggak punya akun FB?
101 |
102 | Anda bisa upload fotonya ke sosmed (buat setting nya “public”) dengan hestek
103 | #PantauFotoUpload lalu tag Twitter
104 | @KawalPemilu2019 atau Facebook
105 | @kawalpemilu.org
106 |
107 |
108 |
109 | {{/inline}}
110 | {{/_layout/static}}
111 |
--------------------------------------------------------------------------------
/src/tabulasi/tps.scss:
--------------------------------------------------------------------------------
1 | @import "../sizes";
2 |
3 | #tps {
4 | margin-top: 20px;
5 |
6 | div.tps {
7 | display: flex;
8 | flex-direction: row;
9 | min-height: 120px;
10 | border-top: 1px solid #ccc;
11 | padding: 10px 0;
12 |
13 | @media #{$phone} {
14 | padding-top: 30px;
15 | }
16 |
17 | > * {
18 | padding-top: 5px;
19 | padding-bottom: 5px;
20 | }
21 |
22 | div.info {
23 | flex-direction: column;
24 | justify-content: center;
25 | text-align: center;
26 | margin-right: 10px;
27 | width: 60px;
28 |
29 | display: flex;
30 |
31 | @media #{$desktop} {
32 | width: 70px;
33 | }
34 |
35 | p {
36 | margin: 0px;
37 | }
38 | p.tpsNo {
39 | font-size: 16px;
40 | font-weight: bold;
41 | }
42 | p.mod {
43 | font-size: 12px;
44 | margin-top: 10px;
45 |
46 | a span {
47 | display: block;
48 | }
49 | }
50 | p.link {
51 | font-size: 12px;
52 | margin-top: 10px;
53 | }
54 | }
55 |
56 | @media #{$phone} {
57 | div.info {
58 | float: left;
59 | position: absolute;
60 | margin-top: -30px;
61 | flex-direction: row;
62 | width: 100%;
63 | justify-content: flex-start;
64 | align-items: center;
65 | background: #ddd;
66 | height: 30px;
67 | box-sizing: border-box;
68 |
69 | p {
70 | margin-left: 5px;
71 | &.mod {
72 | margin: 0 0 0 10px;
73 | flex: 1;
74 | text-align: left;
75 | a span {
76 | display: inline;
77 | }
78 | }
79 | &.link {
80 | margin: 0 10px;
81 | }
82 | }
83 | }
84 | }
85 |
86 | div.sum {
87 | min-width: 120px;
88 | max-width: 200px;
89 | display: flex;
90 | flex-direction: column;
91 | margin-right: 10px;
92 | justify-content: center;
93 |
94 | @media #{$desktop} {
95 | max-width: 405px;
96 | padding-left: 20px;
97 | padding-right: 20px;
98 | }
99 |
100 | @media #{$tablet} {
101 | max-width: 300px;
102 | padding-left: 10px;
103 | padding-right: 10px;
104 | }
105 |
106 | @media #{$phone} {
107 | padding-left: 5px;
108 | padding-right: 5px;
109 | }
110 |
111 | &.nodata {
112 | width: 100%;
113 | max-width: 600px;
114 | }
115 |
116 | p.nodata {
117 | font-style: italic;
118 | font-size: 12px;
119 | text-align: center;
120 | margin: 0px;
121 |
122 | a {
123 | text-decoration: underline;
124 | }
125 | }
126 |
127 | div.values {
128 | flex: 1;
129 |
130 | ul {
131 | padding: 0px;
132 | margin: 0px;
133 |
134 | li {
135 | list-style: none;
136 | font-size: 12px;
137 | line-height: 25px;
138 |
139 | span {
140 | display: inline-block;
141 | }
142 | }
143 | }
144 | ul.summary {
145 | margin-top: 5px;
146 | }
147 |
148 | ul.pilpres {
149 | display: table;
150 |
151 | li {
152 | display: table-row;
153 |
154 | span {
155 | display: table-cell;
156 | }
157 | span {
158 | &.label {
159 | font-weight: bold;
160 | }
161 | &.label,
162 | &.value {
163 | padding-right: 10px;
164 | }
165 | &.value,
166 | &.kpu {
167 | text-align: right;
168 | }
169 | }
170 |
171 | &.diff {
172 | span {
173 | &.value,
174 | &.kpu {
175 | color: red;
176 | }
177 | }
178 | }
179 |
180 | &.header {
181 | span {
182 | font-weight: bold;
183 | }
184 | }
185 | }
186 | }
187 | ul.pileg {
188 | display: flex;
189 | flex-flow: row wrap;
190 |
191 | li {
192 | display: flex;
193 | margin-right: 15px;
194 |
195 | span.label {
196 | font-weight: bold;
197 | width: 60px;
198 | }
199 | span.value {
200 | width: 25px;
201 | text-align: right;
202 | }
203 | }
204 | }
205 | }
206 |
207 | p.lapor {
208 | margin: 10px 0 0 0;
209 | font-size: 12px;
210 | text-align: center;
211 |
212 | &.kpu {
213 | color: red;
214 | }
215 | &.marked,
216 | &.kpu {
217 | max-width: 120px;
218 | }
219 | }
220 | }
221 |
222 | div.photos {
223 | flex: 1;
224 |
225 | display: flex;
226 | overflow-x: auto;
227 |
228 | div.photo {
229 | padding-right: 10px;
230 | background: white;
231 |
232 | p {
233 | margin: 0;
234 | text-align: center;
235 | border: 1px solid #ccc;
236 | padding: 2px;
237 | }
238 | ul.detail {
239 | margin: 0px;
240 | padding: 0px;
241 | display: table;
242 | font-size: 12px;
243 | li {
244 | list-style: none;
245 | display: table-row;
246 | line-height: 20px;
247 | span {
248 | display: table-cell;
249 | }
250 | span.label {
251 | font-weight: bold;
252 | padding-right: 10px;
253 | white-space: nowrap;
254 | }
255 | span.value {
256 | text-align: right;
257 | }
258 | }
259 | }
260 | }
261 | }
262 |
263 | &.pending {
264 | div.info {
265 | background: #ffcc80;
266 | }
267 | }
268 | &.janggal {
269 | div.sum {
270 | background: #ffcdd2;
271 | }
272 | }
273 | }
274 | }
275 |
--------------------------------------------------------------------------------
/src/tabulasi/agg-pilpres-formatter.ts:
--------------------------------------------------------------------------------
1 | import { Entry, round100 } from "./agg-pilpres-common";
2 |
3 | const THRESHOLD_PERCENTAGE = 0.7
4 |
5 | export function _F(n: number): string {
6 | return n.toLocaleString('id')
7 | }
8 |
9 | export function _FSign(n: number): string {
10 | var text = _F(n)
11 | if (n >= 0) text = '+' + text
12 | return text
13 | }
14 |
15 | export class PasFormatter {
16 | static newForPas1(): PasFormatter {
17 | return new PasFormatter(
18 | 'pas1',
19 | (entry) => entry.pas1,
20 | (entry) => entry.pas1Ratio100
21 | )
22 | }
23 |
24 | static newForPas2(): PasFormatter {
25 | return new PasFormatter(
26 | 'pas2',
27 | (entry) => entry.pas2,
28 | (entry) => entry.pas2Ratio100
29 | )
30 | }
31 |
32 | constructor(
33 | private key: string,
34 | private pasFn: (entry: Entry) => number,
35 | private pasRatio100Fn: (entry: Entry) => number
36 | ) {
37 | }
38 |
39 | format(entry: Entry): string {
40 | let showPercentage = entry.tpsEstimasiRatio >= THRESHOLD_PERCENTAGE
41 | let per = showPercentage ? 'per' : ''
42 | let pasRatio100 = this.pasRatio100Fn(entry)
43 | let pas = this.pasFn(entry)
44 | let win = pasRatio100 > 50 ? 'win' : ''
45 |
46 | let s = ''
47 | s += ``
48 | s += `${_F(pas)} `
49 | if (showPercentage)
50 | s += `${_F(pasRatio100)}% `
51 | s += ' '
52 | return s
53 | }
54 | }
55 |
56 | export class PasKpKpuFormatter {
57 | static newForPas1(): PasKpKpuFormatter {
58 | return new PasKpKpuFormatter(
59 | 'pas1',
60 | (entry) => entry.pas1,
61 | (entry) => entry.pas1Ratio100,
62 | (entry) => entry.pas1kpu
63 | )
64 | }
65 |
66 | static newForPas2(): PasKpKpuFormatter {
67 | return new PasKpKpuFormatter(
68 | 'pas2',
69 | (entry) => entry.pas2,
70 | (entry) => entry.pas2Ratio100,
71 | (entry) => entry.pas2kpu
72 | )
73 | }
74 |
75 | constructor(
76 | private key: string,
77 | private pasFn: (entry: Entry) => number,
78 | private pasRatio100Fn: (entry: Entry) => number,
79 | private pasKpuFn: (entry: Entry) => number
80 | ) {
81 | }
82 |
83 | format(entry: Entry): string {
84 | let showPercentage = entry.tpsEstimasiRatio >= THRESHOLD_PERCENTAGE
85 | let per = showPercentage ? 'per' : ''
86 | let pasRatio100 = this.pasRatio100Fn(entry)
87 | let pas = this.pasFn(entry)
88 | let kpu = this.pasKpuFn(entry)
89 | let win = pasRatio100 > 50 ? 'win' : ''
90 | let diff = kpu - pas
91 |
92 | let s = ''
93 | s += ``
94 | s += `${_F(pas)} `
95 | if (showPercentage)
96 | s += `${_F(pasRatio100)}% `
97 | s += `Situng: ${_FSign(diff)} `
98 | s += ' '
99 | return s
100 | }
101 | }
102 |
103 | export class PasKpuFormatter {
104 | static newForPas1(): PasKpuFormatter {
105 | return new PasKpuFormatter(
106 | 'pas1',
107 | (entry) => entry.pas1,
108 | (entry) => entry.pas1kpu,
109 | (entry) => entry.pas1KpuRatio100
110 | )
111 | }
112 | static newForPas2(): PasKpuFormatter {
113 | return new PasKpuFormatter(
114 | 'pas2',
115 | (entry) => entry.pas2,
116 | (entry) => entry.pas2kpu,
117 | (entry) => entry.pas2KpuRatio100
118 | )
119 | }
120 |
121 | constructor(
122 | private key: string,
123 | private pasFn: (entry: Entry) => number,
124 | private pasKpuFn: (entry: Entry) => number,
125 | private pasKpuRatio100Fn: (entry: Entry) => number) { }
126 |
127 | format(entry: Entry): string {
128 | let showPercentage = entry.tpsEstimasiRatio >= THRESHOLD_PERCENTAGE
129 | let pasKpuRatio100 = this.pasKpuRatio100Fn(entry)
130 | let win = pasKpuRatio100 > 50 ? 'win' : ''
131 | let pas = this.pasKpuFn(entry)
132 | let diff = pas - this.pasFn(entry)
133 | let cdiff = diff != 0 ? 'diff' : ''
134 |
135 | let s = ''
136 | s += ``
137 | s += `${_F(pas)} `
138 | s += `(${_FSign(diff)}) `
139 | s += ' '
140 | return s
141 | }
142 | }
143 |
144 | export class EstimasiFormatter {
145 | format(entry: Entry): string {
146 | let tpsEstimasi = (Math.round(entry.tpsEstimasiRatio * 1000) / 10).toLocaleString('id')
147 | let estimasiStyle = this.createEstimasiStyle(entry)
148 | let title = [
149 | `Estimasi TPS terproses: ${_F(entry.tpsEstimasi)} (${tpsEstimasi}%)`,
150 | `TPS dengan Foto: ${_F(entry.cakupan)}`,
151 | `Belum diproses: ${_F(entry.pending)}`,
152 | `Total TPS dari KPU: ${_F(entry.ntps)}`,
153 | ].join("\n")
154 | return `${tpsEstimasi}% `
155 | }
156 |
157 | private createEstimasiStyle(entry: Entry): string {
158 | let pEstimasi = entry.tpsEstimasiRatio * 100
159 | let pCakupan = entry.cakupan / entry.ntps * 100
160 | return `background-image: linear-gradient(to right, #aed581 0, #aed581 ${pEstimasi}%, #fff176 ${pEstimasi}%, #fff176 ${pCakupan}%, #e0e0e0 ${pCakupan}%, #e0e0e0 100%)`
161 | }
162 | }
163 |
164 | export class EstimasiFullFormatter {
165 | format(entry: Entry): string {
166 | var ratio = Math.round(entry.tpsEstimasiRatio * 1000) / 10
167 | var s = `${_F(entry.tpsEstimasi)} `
168 | s += `${_F(ratio)}% `
169 |
170 | let pEstimasi = entry.tpsEstimasiRatio * 100
171 | var style = `background-image: linear-gradient(to right, rgba(241, 248, 233, 1) 0, rgba(197, 225, 165, 1) ${pEstimasi}%, #e0e0e0 ${pEstimasi}%, #e0e0e0 100%)`
172 |
173 | return `${s} `
174 | }
175 | }
176 |
177 | export class EstimasiFull2Formatter {
178 | format(entry: Entry): string {
179 | let tpsEstimasi = (Math.round(entry.tpsEstimasiRatio * 10000) / 100).toLocaleString('id')
180 | let estimasiStyle = this.createEstimasiStyle(entry)
181 | let title = [
182 | `Estimasi TPS terproses: ${_F(entry.tpsEstimasi)} (${tpsEstimasi}%)`,
183 | `Total TPS dengan Foto: ${_F(entry.cakupan)}`,
184 | `Belum diproses: ${_F(entry.pending)}`,
185 | `Total TPS tercatat: ${_F(entry.ntps)}`,
186 | ].join("\n")
187 | return `${_F(entry.tpsEstimasi)} ${tpsEstimasi}% `
188 | }
189 |
190 | private createEstimasiStyle(entry: Entry): string {
191 | let pEstimasi = entry.tpsEstimasiRatio * 100
192 | let pCakupan = entry.cakupan / entry.ntps * 100
193 | return `background-image: linear-gradient(to right, #aed581 0, #aed581 ${pEstimasi}%, #fff176 ${pEstimasi}%, #fff176 ${pCakupan}%, #e0e0e0 ${pCakupan}%, #e0e0e0 100%)`
194 | }
195 | }
196 |
197 | export class SahFormatter {
198 | format(entry: Entry): string {
199 | var error = entry.pas1 + entry.pas2 === entry.sah ? '' : 'error'
200 | var content = `${_F(entry.sah)} `
201 | if (error) content += `(${_FSign(entry.sah - entry.pas1 - entry.pas2)}) `
202 | return `${content} `
203 | }
204 | }
205 |
206 | export class SahKpKpuFormatter {
207 | format(entry: Entry): string {
208 | var error = entry.pas1 + entry.pas2 === entry.sah ? '' : 'error'
209 | var content = `${_F(entry.sah)} `
210 | var diff = entry.sahKpu - entry.sah
211 | if (error) content += `(${_FSign(entry.sah - entry.pas1 - entry.pas2)}) `
212 | content += `Situng: ${_FSign(diff)} `
213 | return `${content} `
214 | }
215 | }
216 |
217 | export class TidakSahFormatter {
218 | format(entry: Entry): string {
219 | return `${_F(entry.tSah)} `
220 | }
221 | }
222 |
223 | export class TidakSahKpKpuFormatter {
224 | format(entry: Entry): string {
225 | var diff = entry.tSahKpu - entry.tSah
226 | var content = `${_F(entry.tSah)} `
227 | content += `Situng: ${_FSign(diff)} `
228 | return `${content} `
229 | }
230 | }
231 |
232 |
233 | export class TpsKpuFormatter {
234 | format(entry: Entry): string {
235 | return `${_F(entry.ntps)} `
236 | }
237 | }
238 |
239 | export class TpsCakupanFormatter {
240 | format(entry: Entry): string {
241 | var per = round100(entry.cakupan / entry.ntps)
242 | return `${_F(entry.cakupan)} ${_F(per)}% `
243 | }
244 | }
245 |
246 | export class TpsPendingFormatter {
247 | format(entry: Entry): string {
248 | return `${_F(entry.pending)} `
249 | }
250 | }
251 |
252 | export class TpsErrorFormatter {
253 | format(entry: Entry): string {
254 | return `${_F(entry.error)} `
255 | }
256 | }
257 |
--------------------------------------------------------------------------------
/src/tabulasi/tps.ts:
--------------------------------------------------------------------------------
1 | import { PageParam, getSumValue } from './common'
2 | import { HierarchyNode, TpsAggregate } from './types'
3 | import { ScreenSize } from './screen';
4 |
5 | export class TpsRenderer {
6 | constructor(
7 | private screenSize: ScreenSize,
8 | private target: HTMLElement) { }
9 |
10 | private KEYS = {
11 | 'pilpres': [
12 | 'pas1', 'pas2'
13 | ],
14 | 'pileg': [
15 | 'pkb', 'ger', 'pdi', 'gol',
16 | 'nas', 'gar', 'ber', 'sej',
17 | 'per', 'ppp', 'psi', 'pan',
18 | 'han', 'dem', 'pa', 'ps',
19 | 'pda', 'pna', 'pbb', 'pkp'
20 | ],
21 | 'summary-pilpres': [
22 | 'sah', 'tSah', 'jum',
23 | ],
24 | 'summary-pileg': [
25 | 'pSah', 'pTSah', 'pJum',
26 | ]
27 | }
28 |
29 | private SUM_LABELS = {
30 | 'pas1': 'Jokowi-Amin',
31 | 'pas2': 'Prabowo-Sandi',
32 |
33 | 'sah': 'Suara Sah',
34 | 'tSah': 'Tidak Sah',
35 | 'jum': 'PHP',
36 |
37 | 'pkb': 'PKB',
38 | 'ger': 'Gerindra',
39 | 'pdi': 'PDI',
40 | 'gol': 'Golkar',
41 | 'nas': 'Nasdem',
42 | 'gar': 'Garuda',
43 | 'ber': 'Berkarya',
44 | 'sej': 'PKS',
45 | 'per': 'Perindo',
46 | 'ppp': 'PPP',
47 | 'psi': 'PSI',
48 | 'pan': 'PAN',
49 | 'han': 'Hanura',
50 | 'dem': 'Demokrat',
51 | 'pa': 'PA',
52 | 'ps': 'PS',
53 | 'pda': 'PDA',
54 | 'pna': 'PNA',
55 | 'pbb': 'PBB',
56 | 'pkp': 'PKP',
57 |
58 | 'pSah': 'Suara Sah',
59 | 'pTSah': 'Tidak Sah',
60 | 'pJum': 'PHP',
61 | }
62 |
63 |
64 | render(param: PageParam, node: HierarchyNode) {
65 | this.target.innerHTML = this._render(param, node)
66 | if (param.tps) this.scrollToTps(param)
67 | }
68 |
69 | private scrollToTps(param: PageParam) {
70 | var id = `tps-${param.id}-${param.tps}`
71 | var el = document.getElementById(id)
72 | if (!el) return
73 | el.scrollIntoView({ block: 'center', behavior: 'smooth' })
74 | }
75 |
76 | private _render(param: PageParam, node: HierarchyNode) {
77 | if (node.depth < 4) {
78 | return ''
79 | }
80 |
81 | var nTps = node.children.length
82 |
83 | var s = ''
84 | for (var i = 0; i < nTps; i++) {
85 | var ch = node.children[i]
86 | var tpsNo = ch[0] as number
87 | var data = node.data[tpsNo]
88 |
89 | s += this.renderTpsEntry(param, node, tpsNo, data)
90 | }
91 | return s
92 | }
93 |
94 | private renderTpsEntry(param: PageParam, node: HierarchyNode, tpsNo: number, data: TpsAggregate | null) {
95 | var modUrl = 'https://upload.kawalpemilu.org/t/' + node.id + '/' + tpsNo + '?utm_source=wwwkp'
96 |
97 | var janggalClass = data && getSumValue(data.sum, 'janggal') ? 'janggal' : ''
98 | var pendingClass = data && getSumValue(data.sum, 'pending') ? 'pending' : ''
99 |
100 | var s = ``
101 | var tpsUrl = `https://kawalpemilu.org/#${param.type}:${param.id}/${tpsNo}`
102 | console.log(tpsUrl)
103 |
104 | // info
105 | s += '
'
110 |
111 | // sum
112 | var tpsSum = this.renderTpsSum(param, node, tpsNo, data)
113 | if (tpsSum) {
114 | s += '
' + tpsSum + '
'
115 | }
116 | else {
117 | s += '
'
118 | }
119 |
120 | // photos
121 | if (tpsSum) {
122 | s += '
'
123 | s += this.renderTpsPhotos(param, node, tpsNo, data)
124 | s += '
'
125 | }
126 |
127 | s += '
'
128 | return s
129 | }
130 |
131 | private getLaporanUrl(param: PageParam, tpsNo: number, kecamatan: string, kelurahan: string) {
132 | var hash = param.type + ':' + param.id
133 | return 'https://docs.google.com/forms/d/e/1FAIpQLSdeoAqXjE-gd_YpsvpzeD1Cr21hWgwKM8MHS8CYXNajD6iKGA/viewform?usp=pp_url&' +
134 | 'entry.1587204645=' + hash +
135 | '&entry.446975413=' + tpsNo +
136 | '&entry.828908754=' + param.type +
137 | '&entry.1325772197=' + kecamatan +
138 | '&entry.789113286=' + kelurahan
139 | }
140 |
141 | private renderTpsSum(param: PageParam, node: HierarchyNode, tpsNo: number, data: TpsAggregate | null) {
142 | if (!data) return ''
143 |
144 | var keys = (this.KEYS as any)[param.type] as string[] // FIXME as any
145 | var summaryKeys = (this.KEYS as any)['summary-' + param.type] as string[] // FIXME as any
146 |
147 | var available = false
148 | for (var i = 0; i < keys.length; i++)
149 | available = available || !!getSumValue(data.sum, keys[i])
150 | if (!available) return ''
151 |
152 | var s = ''
153 |
154 | s += `
`
155 | if (param.type == 'pilpres') {
156 | s += ''
157 | }
158 | for (var i = 0; i < keys.length; i++) {
159 | let key = keys[i]
160 | let label = (this.SUM_LABELS as any)[key] as string // FIXME as any
161 | let sum = getSumValue(data.sum, key)
162 | let kpu = node.kpu && getSumValue(node.kpu[tpsNo], key) || '?'
163 | let diff = kpu !== '?' && sum != kpu ? 'diff' : ''
164 | s += ``
165 | s += `${label} ${sum} `
166 | if (param.type == 'pilpres') {
167 | s += `${kpu} `
168 | }
169 | s += ' '
170 | }
171 | s += ' '
172 |
173 | s += `
`
174 | if (param.type == 'pilpres') {
175 | s += ''
176 | }
177 | for (var i = 0; i < summaryKeys.length; i++) {
178 | let key = summaryKeys[i]
179 | let label = (this.SUM_LABELS as any)[key] as string // FIXME as any
180 | let sum = getSumValue(data.sum, key)
181 | let kpu = node.kpu && getSumValue(node.kpu[tpsNo], key) || '?'
182 | let diff = kpu !== '?' && sum != kpu ? 'diff' : ''
183 | s += ``
184 | s += `${label} ${sum} `
185 | if (param.type == 'pilpres') {
186 | s += `${kpu} `
187 | }
188 | s += ' '
189 | }
190 | s += ' '
191 |
192 | s += '
'
193 |
194 | if (data.sum.laporKpu) {
195 | s += 'TPS ini sudah ditandai memiliki kejanggalan yang perlu dilaporkan ke KPU
'
196 | }
197 | else if (data.sum.janggal) {
198 | s += 'TPS ini sudah ditandai memiliki kejanggalan
'
199 | }
200 | else {
201 | var laporanUrl = this.getLaporanUrl(param, tpsNo, node.parentNames[node.parentNames.length - 1], node.name)
202 | s += 'laporkan kesalahan
';
203 | }
204 | return s
205 | }
206 |
207 | private renderTpsPhotos(param: PageParam, node: HierarchyNode, tpsNo: number, data: TpsAggregate | null) {
208 | if (!data) return ''
209 |
210 | var sumKeys = (this.KEYS as any)[param.type] as string[] // FIXME as any
211 | var summaryKeys = (this.KEYS as any)['summary-' + param.type] as string[] // FIXME as any
212 | var keys = sumKeys.concat(summaryKeys)
213 |
214 | var urls = Object.keys(data.photos)
215 | .filter(url => param.photos.indexOf(data.photos[url].c1.type) >= 0)
216 | .sort((a, b) => {
217 | const pa = data.photos[a]
218 | const pb = data.photos[b]
219 | const ea = !!pa.sum.error ? 1 : 0;
220 | const eb = !!pb.sum.error ? 1 : 0;
221 | const va = ((ea - 1) * 1000 + pa.c1.type * 100 + parseFloat(pa.c1.halaman) * 10 + pa.c1.plano) * 1e14 + pa.ts;
222 | const vb = ((eb - 1) * 1000 + pb.c1.type * 100 + parseFloat(pb.c1.halaman) * 10 + pb.c1.plano) * 1e14 + pb.ts;
223 | return va - vb;
224 | });
225 |
226 | var s = ''
227 | for (var i = 0; i < urls.length; i++) {
228 | let url = urls[i]
229 | let photo = data.photos[url]
230 | let sum = photo.sum
231 | let errorClass = sum.error && sum.error == 1 ? 'error' : ''
232 |
233 | let imageUrl = url.replace('http://', 'https://')
234 |
235 | s += ``
236 | s += `
`
237 |
238 | s += '
'
239 | for (var j = 0; j < keys.length; j++) {
240 | let key = keys[j]
241 | let label = (this.SUM_LABELS as any)[key] as string // FIXME as any
242 | if (!(key in photo.sum))
243 | continue
244 | let sum = getSumValue(photo.sum, key)
245 | s += `${label} ${sum} `
246 | }
247 | s += ' '
248 | s += '
'
249 | }
250 |
251 | return s;
252 | }
253 | }
254 |
--------------------------------------------------------------------------------
/src/tabulasi/agg.scss:
--------------------------------------------------------------------------------
1 | @import "../sizes";
2 |
3 | #agg {
4 | overflow-x: auto;
5 |
6 | table {
7 | border-collapse: collapse;
8 |
9 | margin: 0px;
10 | padding: 0px;
11 |
12 | @media #{$phone} {
13 | min-width: 100%;
14 | }
15 | @media #{$tablet} {
16 | min-width: 100%;
17 | }
18 |
19 | tr {
20 | padding: 0px;
21 | display: table-row;
22 | line-height: 24px;
23 |
24 | @media #{$phone} {
25 | line-height: 24px;
26 | font-size: 14px;
27 | }
28 |
29 | &.header td {
30 | font-weight: bold;
31 | text-align: center !important;
32 |
33 | span {
34 | &.kp {
35 | white-space: nowrap;
36 | font-size: 12px;
37 | @media #{$phone} {
38 | font-size: 11px;
39 | }
40 | }
41 | }
42 | }
43 |
44 | &.footer td {
45 | background: #ddd;
46 |
47 | &.name {
48 | font-weight: bold;
49 | text-align: right;
50 | text-transform: uppercase;
51 | padding-right: 15px;
52 | }
53 | }
54 |
55 | > td {
56 | display: table-cell;
57 | vertical-align: middle;
58 | height: 50px;
59 | box-sizing: border-box;
60 | padding: 5px;
61 | border-right: 1px solid white;
62 |
63 | @media #{$phone-small} {
64 | font-size: 12px;
65 | }
66 | @media #{$phone-wide} {
67 | font-size: 12px;
68 | }
69 |
70 | &.idx {
71 | text-align: center;
72 | @media #{$phone} {
73 | display: none;
74 | }
75 | }
76 | &.name {
77 | width: 250px;
78 | min-width: 250px;
79 | max-width: 250px;
80 | @media #{$phone-small} {
81 | width: 100px;
82 | min-width: 100px;
83 | max-width: 100px;
84 | }
85 | @media #{$phone-wide} {
86 | width: 140px;
87 | min-width: 140px;
88 | max-width: 140px;
89 | }
90 | @media #{$tablet} {
91 | width: 250px;
92 | min-width: 250px;
93 | max-width: 250px;
94 | }
95 |
96 | a {
97 | text-decoration: none;
98 | }
99 | }
100 |
101 | &.sum {
102 | text-align: right;
103 | &.pas {
104 | line-height: 22px;
105 | @media #{$phone} {
106 | line-height: 20px;
107 | }
108 | // font-size: 14px;
109 | span {
110 | &.per {
111 | font-size: 14px;
112 | @media #{$phone} {
113 | font-size: 12px;
114 | }
115 | }
116 | }
117 | &.per {
118 | padding-top: 10px;
119 | padding-bottom: 10px;
120 | }
121 | &.win {
122 | &.per {
123 | background: rgb(241, 248, 233);
124 | background: linear-gradient(135deg, rgba(241, 248, 233, 1) 0%, rgba(197, 225, 165, 1) 100%);
125 |
126 | &.kpu {
127 | background: darken($color: rgb(241, 248, 233), $amount: 10%);
128 | background: linear-gradient(
129 | 135deg,
130 | darken($color: rgba(241, 248, 233, 1), $amount: 20%) 0%,
131 | darken($color: rgba(197, 225, 165, 1), $amount: 20%) 100%
132 | );
133 | }
134 | }
135 |
136 | span.per {
137 | font-weight: bold;
138 | }
139 | }
140 | &.kpu {
141 | span.diff {
142 | color: #888;
143 | }
144 | &.diff {
145 | span.diff {
146 | color: #444;
147 | }
148 | }
149 | }
150 | }
151 |
152 | &.error {
153 | background: rgb(255, 235, 238);
154 | background: linear-gradient(135deg, rgba(255, 235, 238, 1) 0%, rgba(255, 205, 210, 1) 100%);
155 | }
156 |
157 | span {
158 | display: block;
159 |
160 | &.diff {
161 | font-size: 12px;
162 | color: #444;
163 | }
164 | }
165 | }
166 | &.summary {
167 | }
168 | &.tps {
169 | text-align: right;
170 |
171 | &.estimasi {
172 | text-align: center;
173 | span {
174 | border: 1px solid rgba(0, 0, 0, 0.15);
175 | display: inline-block;
176 | width: 100%;
177 | padding: 2px 0;
178 | box-sizing: border-box;
179 | font-size: 12px;
180 |
181 | @media #{$phone} {
182 | font-size: 10px;
183 | }
184 |
185 | @media #{$phone} {
186 | padding: 2px 5px;
187 | }
188 | @media #{$tablet} {
189 | padding: 2px 5px;
190 | }
191 | }
192 |
193 | max-width: 90px;
194 | min-width: 90px;
195 | width: 90px;
196 |
197 | @media #{$phone} {
198 | max-width: 50px;
199 | min-width: 50px;
200 | width: 50px;
201 | }
202 | }
203 | &.estimasi-full2 {
204 | span {
205 | border: 0px;
206 | &.abs {
207 | font-size: 14px;
208 | }
209 | }
210 | }
211 | span {
212 | display: block;
213 |
214 | &.diff {
215 | color: #444;
216 | font-size: 14px;
217 | @media #{$phone} {
218 | font-size: 12px;
219 | }
220 | }
221 | }
222 | }
223 | }
224 | > td:last-child {
225 | border-right-width: 0px;
226 | padding-right: 15px;
227 | @media #{$phone} {
228 | padding-right: 5px;
229 | }
230 | }
231 | }
232 |
233 | tr:nth-child(odd) {
234 | background: white;
235 | // > td.darken {
236 | // background: darken($color: white, $amount: 10%);
237 | // }
238 | }
239 | tr:nth-child(even) {
240 | background: #eceff1;
241 | // > td.darken {
242 | // background: darken($color: #eceff1, $amount: 10%);
243 | // }
244 | }
245 | }
246 |
247 | div.dup-container {
248 | display: none;
249 | &.sticky {
250 | &.header {
251 | display: block;
252 | position: fixed;
253 | top: 0px;
254 | box-shadow: 0px 2px 15px 0 rgba(0, 0, 0, 0.15);
255 | overflow-x: hidden;
256 | background: white;
257 | z-index: 100;
258 | }
259 | &.footer {
260 | background-color: #ddd;
261 | display: block;
262 | position: fixed;
263 | bottom: 0px;
264 | z-index: 100;
265 | box-shadow: 0px 2px 15px 0 rgba(0, 0, 0, 0.15);
266 | }
267 | &.column {
268 | display: block;
269 | position: absolute;
270 |
271 | left: 0px;
272 | top: 0px;
273 |
274 | z-index: 50;
275 | box-shadow: 0px 10px 10px rgba(0, 0, 0, 0.15);
276 | }
277 | &.corner {
278 | display: block;
279 | position: fixed;
280 | top: 0;
281 | left: 0px;
282 | z-index: 150;
283 | box-shadow: 0px 2px 15px 0 rgba(0, 0, 0, 0.15);
284 |
285 | @media #{$phone} {
286 | font-size: 14px;
287 | }
288 | }
289 | &.total {
290 | display: block;
291 | position: fixed;
292 | bottom: 0px;
293 | left: 0px;
294 | z-index: 150;
295 | box-shadow: 0px 2px 15px 0 rgba(0, 0, 0, 0.15);
296 | }
297 |
298 | table {
299 | &.header,
300 | &.footer {
301 | overflow-x: hidden;
302 | display: block;
303 |
304 | tbody {
305 | display: block;
306 | }
307 |
308 | // check _stylesheets/_content.scss
309 | margin-left: 25px;
310 | margin-right: 25px;
311 | @media #{$phone} {
312 | margin-left: 0;
313 | margin-right: 0;
314 | }
315 | }
316 | &.column {
317 | display: block;
318 | }
319 | }
320 | }
321 |
322 | /*
323 | table {
324 | &.dup {
325 | display: none;
326 |
327 | &.sticky {
328 | &.header {
329 | display: block;
330 | position: fixed;
331 | top: 0px;
332 | box-shadow: 0px 2px 15px 0 rgba(0, 0, 0, 0.15);
333 | padding: 0 25px;
334 | margin-left: -25px;
335 | width: 100%;
336 | z-index: 100;
337 | }
338 | &.column {
339 | display: block;
340 | position: absolute;
341 | left: 0px;
342 | top: 0px;
343 | z-index: 50;
344 | box-shadow: 0px 10px 10px #aaa;
345 | }
346 | &.corner {
347 | display: block;
348 | position: fixed;
349 | top: 0;
350 | left: 0px;
351 | z-index: 150;
352 | }
353 | &.footer {
354 | background-color: #ddd;
355 | display: block;
356 | position: fixed;
357 | bottom: 0px;
358 | z-index: 100;
359 | padding: 0 25px;
360 | margin-left: -25px;
361 | width: 100%;
362 | }
363 | }
364 | }
365 | }
366 | */
367 | }
368 |
369 | &.pilpres {
370 | table tr td.tps {
371 | padding-right: 5px;
372 | }
373 | @media #{$desktop} {
374 | table {
375 | min-width: 80%;
376 | }
377 | }
378 | }
379 | &.pileg {
380 | overflow-x: auto;
381 | table {
382 | tr td {
383 | border-right: 1px solid white;
384 | padding-left: 10px;
385 | padding-right: 10px;
386 | }
387 | tr.row td {
388 | &.sum {
389 | font-size: 14px;
390 | }
391 | }
392 | }
393 | }
394 | }
395 |
--------------------------------------------------------------------------------
/src/tabulasi/sticky.ts:
--------------------------------------------------------------------------------
1 | function updateStickyTableRow(rowSelector: string, id: string, classList: string[], showFn: () => boolean) {
2 | var agg = document.getElementById('agg')
3 | var els = document.querySelectorAll(rowSelector)
4 | if (els.length == 0) return;
5 | var el = els[0] as HTMLElement
6 | var table = el.parentElement
7 |
8 | var idContainer = id + '-container'
9 | var container = document.getElementById(idContainer)
10 | var dup = document.getElementById(id)
11 | if (!dup) {
12 | container = document.createElement('div')
13 | container.id = idContainer
14 | container.classList.add('dup-container')
15 | classList.forEach((n) => container.classList.add(n))
16 |
17 | dup = document.createElement('table')
18 | dup.id = id
19 | classList.forEach((n) => dup.classList.add(n))
20 |
21 | if (table.tagName != 'table')
22 | table = table.parentElement
23 |
24 | container.appendChild(dup)
25 | table.parentElement.insertBefore(container, table)
26 |
27 | dup.innerHTML = `${el.innerHTML} `
28 | }
29 |
30 | var tr = dup.querySelector('tr')
31 | for (var i = 0; i < tr.children.length; i++) {
32 | var orig = el.children[i] as HTMLElement
33 | var ch = tr.children[i] as HTMLElement
34 | ch.style.minWidth = orig.offsetWidth + 'px'
35 | ch.style.maxWidth = orig.offsetWidth + 'px'
36 | ch.style.width = orig.offsetWidth + 'px'
37 | }
38 |
39 | if (showFn()) {
40 | dup.classList.add('sticky')
41 | container.classList.add('sticky')
42 | }
43 | else {
44 | dup.classList.remove('sticky')
45 | container.classList.remove('sticky')
46 | }
47 |
48 | var tbody = dup.querySelector('tbody')
49 | tbody.style.marginLeft = (-1 * agg.scrollLeft) + 'px'
50 | container.style.left = agg.parentElement.offsetLeft + 'px'
51 | container.style.width = agg.parentElement.offsetWidth + 'px'
52 | }
53 |
54 | export function updateStickyTableHeader() {
55 | updateStickyTableRow(
56 | '#agg table.table tr.header',
57 | 'agg-dup-table-header',
58 | ['dup', 'header'],
59 | () => {
60 | var table = document.querySelectorAll('#agg table.table')[0] as HTMLElement
61 | return window.pageYOffset > table.offsetTop
62 | })
63 | }
64 |
65 | export function updateStickyTableFooter() {
66 | updateStickyTableRow(
67 | '#agg table.table tr.footer',
68 | 'agg-dup-table-footer',
69 | ['dup', 'footer'],
70 | () => {
71 | var table = document.querySelectorAll('#agg table.table')[0] as HTMLElement
72 | var footer = document.querySelectorAll('#agg table.table tr.footer')[0] as HTMLElement
73 | return table.offsetTop - window.pageYOffset + 180 < window.innerHeight && table.offsetTop + footer.offsetTop - window.pageYOffset + footer.offsetHeight > window.innerHeight
74 | })
75 | }
76 |
77 | export function updateStickyTableColumn() {
78 | var agg = document.getElementById('agg')
79 |
80 | var els = document.querySelectorAll('#agg table.table tr td.name')
81 | if (els.length == 0) return;
82 | var els0 = els[0] as HTMLElement
83 |
84 | var tables = document.querySelectorAll('#agg table.table')
85 | if (tables.length == 0) return;
86 | var table = tables[0] as HTMLElement
87 |
88 | var id = 'agg-dup-table-column'
89 | var idContainer = id + '-container'
90 |
91 | var container = document.getElementById(idContainer)
92 | var dup = document.getElementById(id)
93 | if (!container) {
94 | container = document.createElement('div')
95 | container.id = idContainer
96 | container.classList.add('dup-container')
97 | container.classList.add('column')
98 |
99 | dup = document.createElement('table')
100 | dup.id = id
101 | dup.classList.add('dup')
102 | dup.classList.add('column')
103 |
104 | container.appendChild(dup)
105 | table.parentElement.insertBefore(container, table)
106 | }
107 | let s = ''
108 | for (let i = 0; i < els.length; i++) {
109 | let el = els[i] as HTMLElement
110 | let tr = el.parentElement
111 | let darken = tr.classList.contains('row') ? 'darken' : ''
112 | s += `${els[i].innerHTML} `
113 | }
114 | dup.innerHTML = s
115 |
116 | var widthPx = els0.offsetWidth + 'px'
117 | dup.style.width = widthPx
118 | dup.style.minWidth = widthPx
119 | dup.style.maxWidth = widthPx
120 |
121 | var trs = dup.querySelectorAll('tr')
122 | for (var i = 0; i < trs.length; i++) {
123 | var orig = els[i] as HTMLElement
124 | var ch = trs[i].children[0] as HTMLElement
125 | ch.style.minWidth = widthPx
126 | ch.style.maxWidth = widthPx
127 | ch.style.width = widthPx
128 | ch.style.minHeight = orig.offsetHeight + 'px'
129 | ch.style.maxHeight = orig.offsetHeight + 'px'
130 | ch.style.height = orig.offsetHeight + 'px'
131 | }
132 |
133 | if (agg.scrollLeft > els0.offsetLeft) {
134 | container.classList.add('sticky')
135 | dup.classList.add('sticky')
136 | }
137 | else {
138 | container.classList.remove('sticky')
139 | dup.classList.remove('sticky')
140 | }
141 |
142 | container.style.top = table.offsetTop + 'px'
143 | container.style.left = agg.offsetLeft + 'px'
144 | }
145 |
146 | export function updateStickyTableTotal() {
147 | var agg = document.getElementById('agg')
148 |
149 | var els = document.querySelectorAll('#agg table.table tr.footer td.name')
150 | if (els.length == 0) return;
151 | var els0 = els[0] as HTMLElement
152 |
153 | var tables = document.querySelectorAll('#agg table.table')
154 | if (tables.length == 0) return;
155 | var table = tables[0] as HTMLElement
156 |
157 | var id = 'agg-dup-table-total'
158 | var idContainer = id + '-container'
159 |
160 | var container = document.getElementById(idContainer)
161 | var dup = document.getElementById(id)
162 | if (!container) {
163 | container = document.createElement('div')
164 | container.id = idContainer
165 | container.classList.add('dup-container')
166 | container.classList.add('total')
167 |
168 | dup = document.createElement('table')
169 | dup.id = id
170 | dup.classList.add('dup')
171 | dup.classList.add('total')
172 |
173 | container.appendChild(dup)
174 | table.parentElement.insertBefore(container, table)
175 |
176 | let s = ''
177 | for (let i = 0; i < els.length; i++) {
178 | let el = els[i] as HTMLElement
179 | let li = el.parentElement
180 | s += `${els[i].innerHTML} `
181 | }
182 | dup.innerHTML = s
183 | }
184 |
185 | var widthPx = els0.offsetWidth + 'px'
186 | dup.style.width = widthPx
187 | dup.style.minWidth = widthPx
188 | dup.style.maxWidth = widthPx
189 | container.style.left = agg.offsetLeft + 'px'
190 |
191 | var trs = dup.querySelectorAll('tr')
192 | for (var i = 0; i < trs.length; i++) {
193 | var orig = els[i] as HTMLElement
194 | var ch = trs[i].children[0] as HTMLElement
195 | ch.style.minWidth = widthPx
196 | ch.style.maxWidth = widthPx
197 | ch.style.width = widthPx
198 | ch.style.minHeight = orig.offsetHeight + 'px'
199 | ch.style.maxHeight = orig.offsetHeight + 'px'
200 | ch.style.height = orig.offsetHeight + 'px'
201 | }
202 |
203 | var table = document.querySelectorAll('#agg table.table')[0] as HTMLElement
204 | var footer = document.querySelectorAll('#agg table.table tr.footer')[0] as HTMLElement
205 | if (table.offsetTop - window.pageYOffset + 180 < window.innerHeight && table.offsetTop + footer.offsetTop - window.pageYOffset + footer.offsetHeight > window.innerHeight && agg.scrollLeft > els0.offsetLeft) {
206 | container.classList.add('sticky')
207 | dup.classList.add('sticky')
208 | }
209 | else {
210 | container.classList.remove('sticky')
211 | dup.classList.remove('sticky')
212 | }
213 | }
214 |
215 | export function updateStickyTableCorner() {
216 | var agg = document.getElementById('agg')
217 |
218 | var els = document.querySelectorAll('#agg table.table tr.header td.name')
219 | if (els.length == 0) return;
220 | var els0 = els[0] as HTMLElement
221 |
222 | var tables = document.querySelectorAll('#agg table.table')
223 | if (tables.length == 0) return;
224 | var table = tables[0] as HTMLElement
225 |
226 | var id = 'agg-dup-table-corner'
227 | var idContainer = id + '-container'
228 |
229 | var container = document.getElementById(idContainer)
230 | var dup = document.getElementById(id)
231 | if (!container) {
232 | container = document.createElement('div')
233 | container.id = idContainer
234 | container.classList.add('dup-container')
235 | container.classList.add('corner')
236 |
237 | dup = document.createElement('table')
238 | dup.id = id
239 | dup.classList.add('dup')
240 | dup.classList.add('corner')
241 |
242 | container.appendChild(dup)
243 | table.parentElement.insertBefore(container, table)
244 |
245 | let s = ''
246 | for (let i = 0; i < els.length; i++) {
247 | let el = els[i] as HTMLElement
248 | let li = el.parentElement
249 | s += `${els[i].innerHTML} `
250 | }
251 | dup.innerHTML = s
252 | }
253 |
254 | var widthPx = els0.offsetWidth + 'px'
255 | dup.style.width = widthPx
256 | dup.style.minWidth = widthPx
257 | dup.style.maxWidth = widthPx
258 | container.style.left = agg.offsetLeft + 'px'
259 |
260 | var trs = dup.querySelectorAll('tr')
261 | for (var i = 0; i < trs.length; i++) {
262 | var orig = els[i] as HTMLElement
263 | var ch = trs[i].children[0] as HTMLElement
264 | ch.style.minWidth = widthPx
265 | ch.style.maxWidth = widthPx
266 | ch.style.width = widthPx
267 | ch.style.minHeight = orig.offsetHeight + 'px'
268 | ch.style.maxHeight = orig.offsetHeight + 'px'
269 | ch.style.height = orig.offsetHeight + 'px'
270 | }
271 |
272 | if (window.pageYOffset > table.offsetTop && agg.scrollLeft > els0.offsetLeft) {
273 | container.classList.add('sticky')
274 | dup.classList.add('sticky')
275 | }
276 | else {
277 | container.classList.remove('sticky')
278 | dup.classList.remove('sticky')
279 | }
280 | }
281 |
--------------------------------------------------------------------------------
/src/index2.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | KawalPemilu - Jaga Suara 2019
9 |
10 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
462 |
463 |
1143 |
1144 |
1145 |
1146 |
1149 |
1150 |
1151 |
1159 |
1160 |
1161 |
1162 | Hasil Tabulasi Data Kawal Pemilu 2019
1163 |
1190 |
1191 |
1192 |
1193 |
1194 |
1195 |
1196 |
1197 | sedang membaca data…
1198 |
1199 |
1200 |
1201 |
1202 |
1203 |
Catatan
1204 |
1205 |
1206 | Data Kawal Pemilu Jaga Suara 2019 sudah final dan tidak akan diperbarui lagi.
1207 |
1208 |
1209 | #TPS dengan Foto berisi jumlah TPS yang
1210 | sudah memiliki foto, baik foto suara pilpres (PPWP lembar 2),
1211 | pengguna hak pilih, DPD, atau yang lain; terlepas apakah foto
1212 | tersebut sudah diproses atau belum.
1213 |
1214 |
1215 |
1216 | #TPS terproses berisi
1217 | ESTIMASI jumlah TPS yang sudah memiliki foto dan
1218 | sudah diproses datanya. Kami hanya dapat menampilkan angka
1219 | estimasi karena satu TPS yang sama memiliki beberapa foto dimana
1220 | sebagian sudah diproses dan sebagian lain belum diproses.
1221 |
1222 |
1223 | Kawal Pemilu menerima lebih dari satu foto per TPS selain untuk
1224 | mendapatkan bukti foto pendukung, juga karena Kawal Pemilu
1225 | menerima dan memproses foto terkait pemilu DPR, DPD, DPRD
1226 | Provinsi, dan DPRD Kabupaten/Kota.
1227 |
1228 |
1229 |
1230 | #TPS Foto Belum Diproses berisi jumlah TPS yang memiliki foto
1231 | yang belum diproses.
1232 |
1233 |
1234 | #TPS Foto dengan Error berisi jumlah TPS yang memiliki laporan yang belum
1235 | ditindaklanjuti.
1236 |
1237 |
1238 | Suara Sah atau Hasil Perhitungan
1239 | berwarna merah menandakan adanya jumlah data suara kedua pasangan yang tidak sesuai dengan
1240 | data suara sah. Silakan drill down data lalu temukan dan laporkan data C1 yang bermasalah.
1241 |
1242 |
1243 | TPS pada halaman TPS yang diwarnai oranye artinya
1244 | memiliki data yang belum diproses.
1245 |
1246 |
1247 | TPS pada halaman TPS yang diwarnai kuning artinya
1248 | memiliki data berbeda dari beberapa foto yang berbeda.
1249 |
1250 |
1265 |
1266 |
1267 |
1268 |
1269 | Kawal Pemilu - Jaga Suara 2019
1270 |
1271 |
1272 |
1273 |
1274 |
--------------------------------------------------------------------------------