├── src ├── main.scss ├── static.scss ├── static.js ├── .gitignore ├── main.js ├── _layout │ ├── header.hbs │ ├── static.hbs │ ├── head-ga.hbs │ ├── page.hbs │ ├── body-header.hbs │ ├── head-social.hbs │ └── body-footer.hbs ├── assets │ ├── logo │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── logo-kpjs@2x.png │ │ ├── logo-nav@2x.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── android-icon-192x192.png │ │ └── manifest.json │ ├── Sertifikat-Pemantau.pdf │ ├── Surat-Resmi-KawalPemilu-Jaga-Suara-2019-untuk-TKN-BPN.pdf │ ├── facebook.svg │ ├── twitter.svg │ ├── whatsapp.svg │ └── overlay.svg ├── helpers │ ├── activePage.js │ └── ifEqual.js ├── sizes.scss ├── 404.html ├── tabulasi │ ├── common.ts │ ├── screen.ts │ ├── tabulasi.scss │ ├── page.ts │ ├── nav.scss │ ├── nav.ts │ ├── sort.ts │ ├── types.ts │ ├── agg-pileg.ts │ ├── agg.ts │ ├── agg-pilpres-common.ts │ ├── tabulasi.ts │ ├── agg-pilpres.ts │ ├── tps.scss │ ├── agg-pilpres-formatter.ts │ ├── tps.ts │ ├── agg.scss │ └── sticky.ts ├── kontak.hbs ├── _stylesheets │ ├── _animations.scss │ ├── _footer.scss │ ├── _content.scss │ └── _header.scss ├── index.hbs ├── tentang.hbs ├── page.scss ├── privasi.hbs ├── visualisasi.hbs ├── jenis-peran-pengunjung.hbs ├── disclaimer.hbs ├── faq.hbs └── index2.html ├── .firebaserc ├── postcss.config.js ├── webpack.prod.js ├── README.md ├── firebase.json ├── tsconfig.json ├── webpack.dev.js ├── update.sh ├── scripts └── build-inside-docker.sh ├── LICENSE ├── package.json ├── .gitignore └── webpack.common.js /src/main.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/static.scss: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/static.js: -------------------------------------------------------------------------------- 1 | import './static.scss' -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | *.swo 2 | *.swp 3 | main/vendor/ 4 | 5 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import './page.scss' 2 | import './main.scss' -------------------------------------------------------------------------------- /src/_layout/header.hbs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/logo/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/favicon-16x16.png -------------------------------------------------------------------------------- /src/assets/logo/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/favicon-32x32.png -------------------------------------------------------------------------------- /src/assets/logo/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/favicon-96x96.png -------------------------------------------------------------------------------- /src/assets/logo/logo-kpjs@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/logo-kpjs@2x.png -------------------------------------------------------------------------------- /src/assets/logo/logo-nav@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/logo-nav@2x.png -------------------------------------------------------------------------------- /src/assets/Sertifikat-Pemantau.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/Sertifikat-Pemantau.pdf -------------------------------------------------------------------------------- /src/helpers/activePage.js: -------------------------------------------------------------------------------- 1 | module.exports = function(name) { 2 | if (this.name === name) return "active" 3 | else return "" 4 | }; -------------------------------------------------------------------------------- /src/assets/logo/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/apple-icon-114x114.png -------------------------------------------------------------------------------- /src/assets/logo/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/apple-icon-120x120.png -------------------------------------------------------------------------------- /src/assets/logo/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/apple-icon-144x144.png -------------------------------------------------------------------------------- /src/assets/logo/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/apple-icon-152x152.png -------------------------------------------------------------------------------- /src/assets/logo/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/apple-icon-180x180.png -------------------------------------------------------------------------------- /src/assets/logo/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/apple-icon-57x57.png -------------------------------------------------------------------------------- /src/assets/logo/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/apple-icon-60x60.png -------------------------------------------------------------------------------- /src/assets/logo/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/apple-icon-72x72.png -------------------------------------------------------------------------------- /src/assets/logo/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/apple-icon-76x76.png -------------------------------------------------------------------------------- /src/assets/logo/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/logo/android-icon-192x192.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /* global module, require */ 2 | 3 | module.exports = { 4 | plugins: [ 5 | require( 'autoprefixer' ), 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /src/helpers/ifEqual.js: -------------------------------------------------------------------------------- 1 | module.exports = function (a, b, options) { 2 | if (a == b) return options.fn(this) 3 | else return options.inverse(this) 4 | }; -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'production', 6 | }); 7 | -------------------------------------------------------------------------------- /src/_layout/static.hbs: -------------------------------------------------------------------------------- 1 | {{#> page pageType="static" }} 2 | 3 | {{#*inline "scripts"}} 4 | 7 | {{/inline}} 8 | 9 | {{/page}} 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | KawalPemilu Public Site 2 | ======================= 3 | 4 | This repository contains project files for the public facing site of 5 | KawalPemilu at 6 | 7 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "_public", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/Surat-Resmi-KawalPemilu-Jaga-Suara-2019-untuk-TKN-BPN.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kawalpemilu/kawalpemilu2019-www/HEAD/src/assets/Surat-Resmi-KawalPemilu-Jaga-Suara-2019-untuk-TKN-BPN.pdf -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": true, 5 | "module": "es6", 6 | "target": "es2015", 7 | "jsx": "react", 8 | "allowJs": true 9 | } 10 | } -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'development', 6 | devtool: 'inline-source-map', 7 | devServer: { 8 | contentBase: './dist' 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /src/_layout/head-ga.hbs: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo mkdir -p node_modules 4 | sudo chown -R 1000:1000 node_modules 5 | 6 | sudo rm -rf dist 7 | npm install 8 | npm run build 9 | 10 | sudo mkdir -p _public 11 | sudo rm -rf _public/* 12 | sudo chown -R 1000:1000 _public 13 | 14 | rsync -avH --progress --delete-excluded dist/ _public/ 15 | 16 | -------------------------------------------------------------------------------- /src/sizes.scss: -------------------------------------------------------------------------------- 1 | $phone-small: "only screen and (max-width: 370px)"; 2 | $phone-wide: "only screen and (min-width: 371px) and (max-width: 620px)"; 3 | $phone: "only screen and (max-width: 620px)"; 4 | $tablet: "only screen and (min-width: 621px) and (max-width: 1000px)"; 5 | 6 | $mobile: "only screen and (max-width: 1000px)"; 7 | $desktop: "only screen and (min-width: 1001px)"; 8 | -------------------------------------------------------------------------------- /scripts/build-inside-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | apt update 7 | apt install -y rsync 8 | 9 | rm -rf /kp/_public/ 10 | 11 | pushd /kp/ 12 | rm -rf node_modules dist 13 | 14 | npm install 15 | npm run build 16 | 17 | popd 18 | 19 | # copy for public 20 | 21 | mkdir -p /kp/_public/ 22 | rsync -avH --delete-excluded /kp/dist/ /kp/_public/ 23 | 24 | -------------------------------------------------------------------------------- /src/404.html: -------------------------------------------------------------------------------- 1 | 6 | 😞 oops.. mari kembali ke halaman depan 7 | 8 | -------------------------------------------------------------------------------- /src/assets/facebook.svg: -------------------------------------------------------------------------------- 1 | Facebook icon -------------------------------------------------------------------------------- /src/tabulasi/common.ts: -------------------------------------------------------------------------------- 1 | import { FORM_TYPE, SumMap } from './types' 2 | 3 | export class PageParam { 4 | type: string 5 | form: FORM_TYPE 6 | id: number 7 | tps: number | null 8 | photos: FORM_TYPE[] 9 | } 10 | 11 | export const PageTypes = ['pilpres', 'pileg'] 12 | 13 | export function getSumValue(sum: SumMap | null, key: string): number { 14 | return sum && ((sum as any)[key] as number) || 0 // FIXME as any 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/kontak.hbs: -------------------------------------------------------------------------------- 1 | {{#> _layout/static title="Kontak" name="kontak" }} 2 | {{#*inline "content"}} 3 | 4 |

Kontak Kami

5 | 6 | 11 | 12 | {{/inline}} 13 | {{/_layout/static}} -------------------------------------------------------------------------------- /src/tabulasi/screen.ts: -------------------------------------------------------------------------------- 1 | export class ScreenSize { 2 | properties: Map 3 | 4 | update(properties: Map) { 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 |
2 | 27 |
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 |
  1. 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 |
  2. 15 |
  3. 16 |

    Seluruh relawan dan moderator wajib masuk kedalam akun Facebook terlebih 17 | dahulu saat mengakses laman Kawal Pemilu.

    18 |
  4. 19 |
  5. 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 |
  6. 25 |
  7. 26 |

    Seluruh data profil relawan dan moderator tidak dapat disebarkan oleh Kawal 27 | Pemilu kepada pihak lain.

    28 |
  8. 29 |
  9. 30 |

    Kawal Pemilu dalam mengelola akun milik relawan dan moderator mengikuti 31 | ketentuan layanan dan keamanan dari Facebook.

    32 |
  10. 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 | '', 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 `
    ${s}
` 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 |
  1. 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 |
  2. 17 |
  3. 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 |
  4. 33 |
  5. 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 |
  6. 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 += '' 43 | s += '' 44 | s += '' 45 | PartaiEntries.forEach((e) => { 46 | s += '' 47 | }) 48 | s += '' 49 | 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 += `` 68 | s += `` 69 | PartaiEntries.forEach((e) => { 70 | s += `` 71 | }) 72 | s += `` 73 | s += '' 74 | } 75 | 76 | 77 | s += '
#Wilayah' + e.label + '#TPS KPU
${i + 1}${name}${FS(e.field)}${F(ntps)}
' 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 += '' 27 | s += '' 28 | s += '' 29 | 30 | s += '' 31 | s += '' 32 | 33 | if (mode == 'compact') { 34 | if (this.screenSize.is('phone')) 35 | s += '' 36 | else 37 | s += '' 38 | } 39 | 40 | if (mode == 'full') { 41 | s += '' 42 | s += '' 43 | // s += '' 44 | // s += '' 45 | s += '' 46 | // s += '' 47 | // s += '' 48 | } 49 | 50 | 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 += `` 75 | s += `` 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 += '' 97 | s += '' 98 | s += '' 99 | 100 | s += pas1Fmt.format(total) 101 | s += pas2Fmt.format(total) 102 | 103 | if (mode == 'compact') 104 | s += estFmt.format(total) 105 | 106 | if (mode == 'full') { 107 | s += sahFmt.format(total) 108 | s += tSahFmt.format(total) 109 | // s += tpsKpuFmt.format(total) 110 | // s += tpsCakupanFmt.format(total) 111 | s += estFullFmt.format(total) 112 | // s += tpsPendingFmt.format(total) 113 | // s += tpsErrorFmt.format(total) 114 | } 115 | s += '' 116 | 117 | s += '
#WilayahJokowi-AminPrabowo-SandiEst. TPSEstimasi TPSSuara SahTidak SahTPS
KPU
TPS
Terdata
Estimasi
TPS
Belum
Diproses
Dengan
Laporan
${i + 1}${name}
' 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 |

Tentang upload.kawalpemilu.org

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 += '
' 106 | s += `

TPS ${tpsNo}` 107 | s += `

Mod? ${node.id}/${tpsNo}

` 108 | s += `` 109 | s += '
' 110 | 111 | // sum 112 | var tpsSum = this.renderTpsSum(param, node, tpsNo, data) 113 | if (tpsSum) { 114 | s += '
' + tpsSum + '
' 115 | } 116 | else { 117 | s += '

data belum tersedia

' 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 += '
  • KP KPU
  • ' 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 += '
  • KP KPU
  • ' 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 |
1147 | Kawal Pemilu - Jaga Suara 2019 1148 |
1149 | 1150 | 1160 | 1161 |
1162 |

Hasil Tabulasi Data Kawal Pemilu 2019

1163 | 1190 | 1191 |
1192 | 1193 | 1194 | 1195 | 1196 | 1199 | 1200 |
1197 |

sedang membaca data…

1198 |
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 | 1270 | 1271 | 1272 | 1273 | 1274 | --------------------------------------------------------------------------------