const title1 = "Percentage of total votes by party"; const title2 = "Seat count breakdown"; const title3 = "Ridings lost and gained"; const title4 = "Ridings by province"; const title5 = "Close races"; const title6 = "Where parties did best"; const results = { 2019: { url: "https://beta.ctvnews.ca/content/dam/common/exceltojson/ctv-election-results-2019.txt", data: null, }, 2021: { url: "https://beta.ctvnews.ca/content/dam/common/exceltojson/ctv-election-results-2021.txt", data: null, }, }; const ridingIndex = { "001": ["Avalon", "NL", 11], "002": ["Bonavista-Burin-Trinity", "NL", 27], "003": ["Coast of Bays-Central-Notre Dame", "NL", 71], "004": ["Labrador", "NL", 149], "005": ["Long Range Mountains", "NL", 164], "006": ["St. John's East", "NL", 289], "007": ["St. John's South-Mount Pearl", "NL", 290], "008": ["Cardigan", "PE", 56], "009": ["Charlottetown", "PE", 64], "010": ["Egmont", "PE", 99], "011": ["Malpeque", "PE", 171], "012": ["Cape Breton-Canso", "NS", 55], "013": ["Central Nova", "NS", 60], "014": ["Cumberland-Colchester", "NS", 76], "015": ["Dartmouth-Cole Harbour", "NS", 78], "016": ["Halifax", "NS", 121], // without Sable Island "017": ["Halifax West", "NS", 120], "018": ["Kings-Hants", "NS", 140], "019": ["Sackville-Preston-Chezzetcook", "NS", 255], "020": ["South Shore-St. Margarets", "NS", 284], "021": ["Sydney-Victoria", "NS", 297], "022": ["West Nova", "NS", 323], "023": ["Acadie-Bathurst", "NB", 4], "024": ["Beaus\xe9jour", "NB", 23], "025": ["Fredericton", "NB", 111], "026": ["Fundy Royal", "NB", 112], "027": ["Madawaska-Restigouche", "NB", 170], "028": ["Miramichi-Grand Lake", "NB", 180], "029": ["Moncton-Riverview-Dieppe", "NB", 188], "030": ["New Brunswick Southwest", "NB", 197], "031": ["Saint John-Rothesay", "NB", 257], "032": ["Tobique-Mactaquac", "NB", 304], "033": ["Abitibi-Baie-James-Nunavik-Eeyou", "QC", 2], "034": ["Abitibi-T\xe9miscamingue", "QC", 3], "035": ["Ahuntsic-Cartierville", "QC", 5], "036": ["Alfred-Pellan", "QC", 7], "037": ["Argenteuil-La Petite-Nation", "QC", 9], "038": ["Avignon-La Mitis-Matane-Matap\xe9dia", "QC", 12], "039": ["Beauce", "QC", 20], "040": ["Beauport-Limoilou", "QC", 22], "041": ["B\xe9cancour-Nicolet-Saurel", "QC", 43], "042": ["Bellechasse-Les Etchemins-L\xe9vis", "QC", 24], "043": ["Beloeil-Chambly", "QC", 25], "044": ["Berthier-Maskinong\xe9", "QC", 26], "045": ["Th\xe9r\xe8se-De Blainville", "QC", 302], "046": ["Pierre-Boucher-Les Patriotes-Verch\xe8res", "QC", 229], "047": ["Bourassa", "QC", 28], "048": ["Brome-Missisquoi", "QC", 37], "049": ["Brossard-Saint-Lambert", "QC", 38], "050": ["Rimouski-Neigette-T\xe9miscouata-Les Basques", "QC", 250], "051": ["Charlesbourg-Haute-Saint-Charles", "QC", 62], "052": [ "Beauport-C\xf4te-de-Beaupr\xe9-\xCEle d'Orl\xe9ans-Charlevoix", "QC", 21, ], "053": ["Ch\xe2teauguay-Lacolle", "QC", 69], "054": ["Chicoutimi-Le Fjord", "QC", 66], "055": ["Compton-Stanstead", "QC", 72], "056": ["Dorval-Lachine-LaSalle", "QC", 86], "057": ["Drummond", "QC", 87], "058": ["Gasp\xe9sie-Les \xCEles-de-la-Madeleine", "QC", 113], "059": ["Gatineau", "QC", 114], "060": ["Hochelaga", "QC", 127], "061": ["Honor\xe9-Mercier", "QC", 128], "062": ["Hull-Aylmer", "QC", 129], "063": ["Joliette", "QC", 132], "064": ["Jonqui\xe8re", "QC", 133], "065": ["La Pointe-de-l'\xCEle", "QC", 146], "066": ["La Prairie", "QC", 147], "067": ["Lac-Saint-Jean", "QC", 150], "068": ["Lac-Saint-Louis", "QC", 151], "069": ["LaSalle-\xc9mard-Verdun", "QC", 148], "070": ["Laurentides-Labelle", "QC", 156], "071": ["Laurier-Sainte-Marie", "QC", 157], "072": ["Laval-Les \xCEles", "QC", 158], "073": ["Longueuil-Charles-LeMoyne", "QC", 165], "074": ["L\xe9vis-Lotbini\xe8re", "QC", 169], "075": ["Longueuil-Saint-Hubert", "QC", 166], "076": ["Louis-H\xe9bert", "QC", 167], "077": ["Louis-Saint-Laurent", "QC", 168], "078": ["Manicouagan", "QC", 172], "079": ["M\xe9gantic-L'\xc9rable", "QC", 194], "080": ["Mirabel", "QC", 179], "081": ["Montarville", "QC", 189], "082": ["Montcalm", "QC", 190], "083": ["Montmagny-L'Islet-Kamouraska-Rivi\xe8re-du-Loup", "QC", 191], "084": ["Mount Royal", "QC", 193], "085": ["Notre-Dame-de-Gr\xe2ce-Westmount", "QC", 210], "086": ["Outremont", "QC", 220], "087": ["Papineau", "QC", 222], "088": ["Pierrefonds-Dollard", "QC", 230], "089": ["Pontiac", "QC", 232], "090": ["Portneuf-Jacques-Cartier", "QC", 235], "091": ["Qu\xe9bec", "QC", 239], "092": ["Repentigny", "QC", 246], "093": ["Richmond-Arthabaska", "QC", 249], "094": ["Rivi\xe8re-des-Mille-\xCEles", "QC", 251], "095": ["Rivi\xe8re-du-Nord", "QC", 252], "096": ["Rosemont-La Petite-Patrie", "QC", 253], "097": ["Marc-Aur\xe8le-Fortin", "QC", 173], "098": ["Saint-Hyacinthe-Bagot", "QC", 258], "099": ["Saint-Jean", "QC", 259], 100: ["Saint-Laurent", "QC", 260], 101: ["Saint-L\xe9onard-Saint-Michel", "QC", 261], 102: ["Saint-Maurice-Champlain", "QC", 262], 103: ["Salaberry-Suro\xeet", "QC", 263], 104: ["Shefford", "QC", 276], 105: ["Sherbrooke", "QC", 277], 106: ["Vaudreuil-Soulanges", "QC", 316], 107: ["Terrebonne", "QC", 298], 108: ["Trois-Rivi\xe8res", "QC", 308], 109: ["Ville-Marie-Le Sud-Ouest-\xCEle-des-Soeurs", "QC", 319], 110: ["Vimy", "QC", 320], 111: ["Ajax", "ON", 6], 112: ["Algoma-Manitoulin-Kapuskasing", "ON", 8], 113: ["Aurora-Oak Ridges-Richmond Hill", "ON", 10], 114: ["Barrie-Innisfil", "ON", 14], 115: ["Barrie-Springwater-Oro-Medonte", "ON", 15], 116: ["Bay of Quinte", "ON", 18], 117: ["Beaches-East York", "ON", 19], 118: ["Brampton Centre", "ON", 30], 119: ["Brampton East", "ON", 31], 120: ["Brampton North", "ON", 32], 121: ["Brampton South", "ON", 33], 122: ["Brampton West", "ON", 34], 123: ["Brantford-Brant", "ON", 36], 124: ["Bruce-Grey-Owen Sound", "ON", 39], 125: ["Burlington", "ON", 40], 126: ["Cambridge", "ON", 54], 127: ["Chatham-Kent-Leamington", "ON", 65], 128: ["Davenport", "ON", 80], 129: ["Don Valley East", "ON", 83], 130: ["Don Valley North", "ON", 84], 131: ["Don Valley West", "ON", 85], 132: ["Dufferin-Caledon", "ON", 88], 133: ["Durham", "ON", 89], 134: ["Eglinton-Lawrence", "ON", 98], 135: ["Elgin-Middlesex-London", "ON", 100], 136: ["Essex", "ON", 103], 137: ["Etobicoke Centre", "ON", 104], 138: ["Etobicoke-Lakeshore", "ON", 106], 139: ["Etobicoke North", "ON", 105], 140: ["Flamborough-Glanbrook", "ON", 107], 141: ["Glengarry-Prescott-Russell", "ON", 115], 142: ["Guelph", "ON", 117], 143: ["Haldimand-Norfolk", "ON", 118], 144: ["Haliburton-Kawartha Lakes-Brock", "ON", 119], 145: ["Hamilton Centre", "ON", 122], 146: ["Hamilton East-Stoney Creek", "ON", 123], 147: ["Hamilton Mountain", "ON", 124], 148: ["Hamilton West-Ancaster-Dundas", "ON", 125], 149: ["Hastings-Lennox and Addington", "ON", 126], 150: ["Huron-Bruce", "ON", 131], 151: ["Kanata-Carleton", "ON", 135], 152: ["Kenora", "ON", 137], 153: ["King-Vaughan", "ON", 139], 154: ["Kingston and the Islands", "ON", 141], 155: ["Kitchener Centre", "ON", 142], 156: ["Kitchener-Conestoga", "ON", 144], 157: ["Kitchener South-Hespeler", "ON", 143], 158: ["Lambton-Kent-Middlesex", "ON", 153], 159: ["Lanark-Frontenac-Kingston", "ON", 154], 160: ["Leeds-Grenville-Thousand Islands and Rideau Lakes", "ON", 159], 161: ["London-Fanshawe", "ON", 163], 162: ["London North Centre", "ON", 161], 163: ["London West", "ON", 162], 164: ["Markham-Stouffville", "ON", 174], 165: ["Markham-Thornhill", "ON", 175], 166: ["Markham-Unionville", "ON", 176], 167: ["Milton", "ON", 178], 168: ["Mississauga Centre", "ON", 182], 169: ["Mississauga East-Cooksville", "ON", 183], 170: ["Mississauga-Erin Mills", "ON", 184], 171: ["Mississauga-Lakeshore", "ON", 185], 172: ["Mississauga-Malton", "ON", 186], 173: ["Mississauga-Streetsville", "ON", 187], 174: ["Nepean", "ON", 196], 175: ["Newmarket-Aurora", "ON", 199], 176: ["Niagara Centre", "ON", 200], 177: ["Niagara Falls", "ON", 201], 178: ["Niagara West", "ON", 202], 179: ["Nickel Belt", "ON", 203], 180: ["Nipissing-Timiskaming", "ON", 204], 181: ["Northumberland-Peterborough South", "ON", 208], 182: ["Oakville", "ON", 213], 183: ["Oakville North-Burlington", "ON", 212], 184: ["Oshawa", "ON", 215], 185: ["Ottawa Centre", "ON", 216], 186: ["Orl\xe9ans", "ON", 214], 187: ["Ottawa South", "ON", 217], 188: ["Ottawa-Vanier", "ON", 219], 189: ["Ottawa West-Nepean", "ON", 218], 190: ["Oxford", "ON", 221], 191: ["Parkdale-High Park", "ON", 223], 192: ["Parry Sound-Muskoka", "ON", 224], 193: ["Perth-Wellington", "ON", 226], 194: ["Peterborough-Kawartha", "ON", 227], 195: ["Pickering-Uxbridge", "ON", 228], 196: ["Renfrew-Nipissing-Pembroke", "ON", 245], 197: ["Richmond Hill", "ON", 248], 198: ["Carleton", "ON", 58], 199: ["St. Catharines", "ON", 288], 200: ["Toronto-St. Paul's", "ON", 307], 201: ["Sarnia-Lambton", "ON", 264], 202: ["Sault Ste. Marie", "ON", 268], 203: ["Scarborough-Agincourt", "ON", 272], 204: ["Scarborough Centre", "ON", 269], 205: ["Scarborough-Guildwood", "ON", 273], 206: ["Scarborough North", "ON", 270], 207: ["Scarborough-Rouge Park", "ON", 274], 208: ["Scarborough Southwest", "ON", 271], 209: ["Simcoe-Grey", "ON", 280], 210: ["Simcoe North", "ON", 279], 211: ["Spadina-Fort York", "ON", 286], 212: ["Stormont-Dundas-South Glengarry", "ON", 292], 213: ["Sudbury", "ON", 294], 214: ["Thornhill", "ON", 299], 215: ["Thunder Bay-Rainy River", "ON", 300], 216: ["Thunder Bay-Superior North", "ON", 301], 217: ["Timmins-James Bay", "ON", 303], 218: ["Toronto Centre", "ON", 305], 219: ["Toronto-Danforth", "ON", 306], 220: ["University-Rosedale", "ON", 309], 221: ["Vaughan-Woodbridge", "ON", 317], 222: ["Waterloo", "ON", 321], 223: ["Wellington-Halton Hills", "ON", 322], 224: ["Whitby", "ON", 325], 225: ["Willowdale", "ON", 326], 226: ["Windsor-Tecumseh", "ON", 328], 227: ["Windsor West", "ON", 327], 228: ["York Centre", "ON", 334], 229: ["York-Simcoe", "ON", 336], 230: ["York South-Weston", "ON", 335], 231: ["Humber River-Black Creek", "ON", 130], 232: ["Brandon-Souris", "MB", 35], 233: ["Charleswood-St. James-Assiniboia-Headingley", "MB", 63], 234: ["Churchill-Keewatinook Aski", "MB", 68], 235: ["Dauphin-Swan River-Neepawa", "MB", 79], 236: ["Elmwood-Transcona", "MB", 101], 237: ["Kildonan-St. Paul", "MB", 138], 238: ["Portage-Lisgar", "MB", 234], 239: ["Provencher", "MB", 238], 240: ["Saint Boniface-Saint Vital", "MB", 256], 241: ["Selkirk-Interlake-Eastman", "MB", 275], 242: ["Winnipeg Centre", "MB", 329], 243: ["Winnipeg North", "MB", 330], 244: ["Winnipeg South", "MB", 332], 245: ["Winnipeg South Centre", "MB", 331], 246: ["Battlefords-Lloydminster", "SK", 17], 247: ["Cypress Hills-Grasslands", "SK", 77], 248: ["Desneth\xe9-Missinippi-Churchill River", "SK", 82], 249: ["Carlton Trail-Eagle Creek", "SK", 59], 250: ["Moose Jaw-Lake Centre-Lanigan", "SK", 192], 251: ["Prince Albert", "SK", 236], 252: ["Regina-Lewvan", "SK", 242], 253: ["Regina-Qu'Appelle", "SK", 243], 254: ["Regina-Wascana", "SK", 244], 255: ["Saskatoon-Grasswood", "SK", 266], 256: ["Saskatoon-University", "SK", 267], 257: ["Saskatoon West", "SK", 265], 258: ["Souris-Moose Mountain", "SK", 282], 259: ["Yorkton-Melville", "SK", 337], 260: ["Banff-Airdrie", "AB", 13], 261: ["Battle River-Crowfoot", "AB", 16], 262: ["Bow River", "AB", 29], 263: ["Calgary Centre", "AB", 44], 264: ["Calgary Confederation", "AB", 45], 265: ["Calgary Forest Lawn", "AB", 46], 266: ["Calgary Heritage", "AB", 47], 267: ["Calgary Midnapore", "AB", 48], 268: ["Calgary Nose Hill", "AB", 49], 269: ["Calgary Rocky Ridge", "AB", 50], 270: ["Calgary Shepard", "AB", 51], 271: ["Calgary Signal Hill", "AB", 52], 272: ["Calgary Skyview", "AB", 53], 273: ["Edmonton Centre", "AB", 90], 274: ["Edmonton Griesbach", "AB", 91], 275: ["Edmonton Manning", "AB", 92], 276: ["Edmonton Mill Woods", "AB", 93], 277: ["Edmonton Riverbend", "AB", 94], 278: ["Edmonton Strathcona", "AB", 95], 279: ["Edmonton West", "AB", 96], 280: ["Edmonton-Wetaskiwin", "AB", 97], 281: ["Foothills", "AB", 109], 282: ["Fort McMurray-Cold Lake", "AB", 110], 283: ["Grande Prairie-Mackenzie", "AB", 116], 284: ["Lakeland", "AB", 152], 285: ["Lethbridge", "AB", 160], 286: ["Medicine Hat-Cardston-Warner", "AB", 177], 287: ["Peace River-Westlock", "AB", 225], 288: ["Red Deer-Mountain View", "AB", 241], 289: ["Red Deer-Lacombe", "AB", 240], 290: ["St. Albert-Edmonton", "AB", 287], 291: ["Sherwood Park-Fort Saskatchewan", "AB", 278], 292: ["Sturgeon River-Parkland", "AB", 293], 293: ["Yellowhead", "AB", 333], 294: ["Abbotsford", "BC", 1], 295: ["Burnaby North-Seymour", "BC", 41], 296: ["Burnaby South", "BC", 42], 297: ["Cariboo-Prince George", "BC", 57], 298: ["Central Okanagan-Similkameen-Nicola", "BC", 61], 299: ["Chilliwack-Hope", "BC", 67], 300: ["Cloverdale-Langley City", "BC", 70], 301: ["Coquitlam-Port Coquitlam", "BC", 73], 302: ["Courtenay-Alberni", "BC", 74], 303: ["Cowichan-Malahat-Langford", "BC", 75], 304: ["Delta", "BC", 81], 305: ["Fleetwood-Port Kells", "BC", 108], 306: ["Kamloops-Thompson-Cariboo", "BC", 134], 307: ["Kelowna-Lake Country", "BC", 136], 308: ["Kootenay-Columbia", "BC", 145], 309: ["Langley-Aldergrove", "BC", 155], 310: ["Mission-Matsqui-Fraser Canyon", "BC", 181], 311: ["Nanaimo-Ladysmith", "BC", 195], 312: ["New Westminster-Burnaby", "BC", 198], 313: ["North Okanagan-Shuswap", "BC", 206], 314: ["North Vancouver", "BC", 207], 315: ["Pitt Meadows-Maple Ridge", "BC", 231], 316: ["Port Moody-Coquitlam", "BC", 233], 317: ["Prince George-Peace River-Northern Rockies", "BC", 237], 318: ["Richmond Centre", "BC", 247], 319: ["Esquimalt-Saanich-Sooke", "BC", 102], 320: ["Saanich-Gulf Islands", "BC", 254], 321: ["Skeena-Bulkley Valley", "BC", 281], 322: ["South Okanagan-West Kootenay", "BC", 283], 323: ["South Surrey-White Rock", "BC", 285], 324: ["Steveston-Richmond East", "BC", 291], 325: ["Surrey Centre", "BC", 295], 326: ["Surrey-Newton", "BC", 296], 327: ["Vancouver Centre", "BC", 310], 328: ["Vancouver East", "BC", 311], 329: ["Vancouver Granville", "BC", 312], 330: ["North Island-Powell River", "BC", 205], 331: ["Vancouver Kingsway", "BC", 313], 332: ["Vancouver Quadra", "BC", 314], 333: ["Vancouver South", "BC", 315], 334: ["Victoria", "BC", 318], 335: ["West Vancouver-Sunshine Coast-Sea to Sky Country", "BC", 324], 336: ["Yukon", "YT", 338], 337: ["Northwest Territories", "NT", 209], 338: ["Nunavut", "NU", 211], }; const regionsArray = [ "Canada", [ "British Columbia", [ "Metro Vancouver", ["Vancouver", "327-329,331-333"], ["Burnaby", "295,296,312"], ["North Shore", "295,314,335"], ["Richmond/Delta", "304,318,324"], ["Surrey", "300,305,323,325,326"], "301,316", ], ["Vancouver Island", ["Victoria", "319,320,334"], "302,303,311,330"], ["Northern B.C.", "297,317,321"], ["Fraser Valley", "294,299,309,310,315"], ["Okanagan / Kootenay", "298,306-308,313,322"], ], [ "Alberta", ["Greater Edmonton", "273-280,290"], ["Calgary", "263-272"], ["Northern Alberta", "261,282-284,287,291-293"], ["Southern Alberta", "260,262,281,285,286,288,289"], ], [ "Saskatchewan", ["Regina", "252-254"], ["Saskatoon", "255-257"], "246-251,258,259", ], [ "Manitoba", ["Winnipeg", "233,236,237,240,242-245"], "232,234,235,238,239,241", ], [ "Ontario", [ "Toronto", "117,128-131,134,137-139,191,200,203-208,211,218-220,225,228,230,231", ], ["Greater Ottawa", "151,174,185-189,198"], // Ottawa? ["Brampton", "118-122"], ["Durham Region", "111,133,184,195,224"], ["Halton Region", "125,167,182,183"], ["Hamilton", "140,145-148"], ["Mississauga", "168-173"], ["Niagara", "176-178,199"], ["York Region", "113,153,164-166,175,197,214,221,229"], ["Northern Ontario", "112,152,179,180,192,202,213,215-217"], ["Central Ontario", "114,115,124,144,209,210"], ["Eastern Ontario", "116,141,149,154,159,160,181,194,196,212"], [ "Midwestern Ontario", ["Waterloo Region", "126,155-157,222"], "123,132,142,143,150,190,193,223", ], [ "Southwestern Ontario", ["London", "135,161-163"], ["Windsor", "226,227"], "127,136,158,201", ], ], [ "Quebec", ["Montreal Island", "35,47,56,60,61,65,68,69,71,84-88,96,100,101,109"], ["Laval", "36,72,97,110"], ["Quebec City Region", ["Quebec City", "40,51,76,77,91"], "42,52,74,90"], ["Trois-Rivieres", "41,44,108"], ["Central / Northern Quebec", "33,34,38,50,54,58,63,64,67,70,78,83,89,102"], ["Laurentides / Outaouais", "37,59,62,70,89"], ["C\xf4te-Nord", "45,80,82,92,94,95,107"], // disambiguate with BC ["Eastern Townships", "39,48,55,57,79,93,98,104,105"], ["Monteregie", ["South Shore", "43,46,49,66,73,75,81"], "53,99,103,106"], ], ["New Brunswick", "23-32"], ["Nova Scotia", ["Halifax", "15-17,19"], "12-14,18,20-22"], ["Prince Edward Island", "8-11"], ["Newfoundland and Labrador", "1-7"], ["Territories", "336-338"], ]; const partyData = [ { short: "LIB", long: "Liberal Party", color: "" }, { short: "CON", long: "Conservative Party", color: "", }, { short: "BQ", long: "Bloc Quebecois", color: "", }, { short: "NDP", long: "New Democratic Party", color: "", }, { short: "GRN", long: "Green Party", color: "", }, { short: "IND", long: "Independent", color: "", }, { short: "PPC", long: "People's Party of Canada", color: "", }, ]; const fetchData = () => { const years = ["2019", "2021"]; Promise.all(years.map((d) => d3.json(results[d].url))).then( ([results2019, results2021]) => { const previousElection = new ResultsData( results2019.QueryResults, "2019" ); const currentElection = new ResultsData(results2021.QueryResults, "2021"); // const currentElection = new ResultsData(local2021.QueryResults, "2021"); createPercentageChart(currentElection, previousElection); createSeatChart(currentElection, previousElection); createGainsLosses(currentElection, previousElection); createRegions(currentElection, previousElection); createCloseRaceChart(currentElection, previousElection); createBestRidings(currentElection, previousElection); } ); }; class ResultsData { constructor(data, year) { this.year = year; this.raw = data; this.processData(this.raw); } processData(data) { this.standings = data.find((query) => query.queryType === "STA"); this.ridings = data.filter((query) => query.queryType === "GRP"); this.ridings.forEach((riding) => { riding.province = ridingIndex[String(riding.RaceNbr).padStart(3, "0")][1]; const totalVotes = riding.Rows.reduce( (memo, curr) => (memo += this.commaStringToNum(curr.Votes)), 0 ); riding.Rows.forEach((candidate) => { candidate.Votes = this.commaStringToNum(candidate.Votes); candidate.VotePercentLong = (100 * candidate.Votes) / totalVotes; }); riding.Rows.sort((a, b) => b.Votes - a.Votes); riding.margin = {}; riding.margin.votes = riding.Rows[0].Votes - riding.Rows[1].Votes; riding.margin.percent = riding.Rows[0].VotePercentLong - riding.Rows[1].VotePercentLong; }); this.ridingsWonCache = {}; this.totals = { votes: { total: 0, parties: [], }, }; this.parties = {}; this.createPartyData(); this.totals.votes.parties.forEach( (party) => (party.percentage = 100 * (party.votes / this.totals.votes.total)) ); this.totals.votes.parties.sort((a, b) => b.votes - a.votes); } createPartyData() { this.ridings.forEach((riding) => { riding.Rows.forEach((candidate) => this.createResults(candidate.PartyName) ); }); } ridingsInRegion(regionName) { let found; const findRegionArray = (name, array) => { if (!Array.isArray(array)) return; if (array[0] === name) { found = array; } const newArray = array.filter((_, i) => i > 0); for (let i = 0; i < newArray.length; i++) { findRegionArray(name, newArray[i]); } }; const getRidingNumbers = (region) => { const ridings = []; const parse = (array) => { array.forEach((item, i) => { if (Array.isArray(item)) { parse(item); } else { if (i > 0) { ridings.push(item); } } }); }; parse(region); return ridings; }; const parseRidingNumbers = (array) => { const parsed = array.map((d) => { const mapped = d.split(","); const numbers = mapped.map((f) => { if (f.includes("-")) { const split = f.split("-").map((g) => parseInt(g)); const newArray = []; for (let i = split[0]; i <= split[1]; i++) { newArray.push(i); } return newArray; } else { return parseInt(f); } }); return numbers; }); return [...new Set(parsed.flat(2))]; }; findRegionArray(regionName, regionsArray); return parseRidingNumbers(getRidingNumbers(found)).map((n) => this.ridings.find((d) => +d.RaceNbr === n) ); } gainsLosses(compareYear) { const parties = { create(party) { this[party] = { gains: [], losses: [], }; }, add(prevRiding, riding) { const winner = riding.Rows.find((cand) => cand.ElectedFlag === "E"); const prevWinner = prevRiding.Rows.find( (cand) => cand.ElectedFlag === "E" ); if (!parties[winner.PartyName]) { this.create(winner.PartyName); } parties[winner.PartyName].gains.push({ riding: riding, from: prevWinner, }); if (!parties[prevWinner.PartyName]) { this.create(prevWinner.PartyName); } parties[prevWinner.PartyName].losses.push({ riding: riding, from: winner, }); }, }; this.ridings.forEach((riding) => { const prevRiding = compareYear.ridings.find( (past) => past.RaceNbr === riding.RaceNbr ); const winner = riding.Rows.find((cand) => cand.ElectedFlag === "E"); const prevWinner = prevRiding.Rows.find( (cand) => cand.ElectedFlag === "E" ); if (winner && prevWinner) { if (winner.PartyName !== prevWinner.PartyName) { parties.add(prevRiding, riding); } } }); return parties; } closeRaces(threshold, sortLowToHigh = true) { const sortFn = sortLowToHigh ? (a, b) => a.margin.percent - b.margin.percent : (a, b) => b.margin.percent - a.margin.percent; const sortedClose = this.ridings .filter(threshold ? (d) => d.VotePercentLong <= threshold : (d) => d) .sort(sortFn); return sortedClose; } sortByBest(party) { return [...this.ridings] .filter((d) => d.Rows.find((p) => p.PartyName === party)) .sort((a, b) => { const B = b.Rows.find((p) => p.PartyName === party); const A = a.Rows.find((p) => p.PartyName === party); return B.VotePercent - A.VotePercent; }); } commaStringToNum(string) { return parseFloat(string.replace(/,/g, "")); } createResults(partyCode) { const party = partyCode.substring(0, 3) === "IND" ? "IND" : partyCode; if (this.parties[party]) { return; } const stats = {}; const candidates = this.ridings .map((riding) => riding.Rows.filter( (candidate) => candidate.PartyName.substring(0, 3) === party ) ) .flat() .filter((d) => d); stats.candidates = { all: candidates, elected: candidates.filter((d) => d.ElectedFlag === "E"), }; stats.votes = { total: stats.candidates.all.reduce((memo, curr) => memo + curr.Votes, 0), }; this.totals.votes.total += stats.votes.total; this.totals.votes.parties.push({ party: party, votes: stats.votes.total }); this.parties[party] = stats; } ridingsWon(party) { const wonSeatArray = this.ridings.filter((riding) => { const winner = riding.Rows.find( (candidate) => candidate.ElectedFlag === "E" ); return winner?.PartyName.substring(0, 3) === party; }); this.ridingsWonCache[party] = wonSeatArray; return this.ridingsWonCache[party]; } } class Table { constructor(parent, columns, data, key, options = {}) { this.parent = parent; this.key = key; this.raw = data; this.columns = columns; this.options = options; this.data = this.raw; this.table = { head: this.parent .append("div") .attr("class", "table-head bold") .classed("striped", this.options.striped), body: this.parent .append("div") .attr("class", "table-body") .classed("striped", this.options.striped) .style("max-height", this.options.maxHeight || "none"), }; this.draw(); } get gridColumns() { return this.columns.map((d) => d.width).join(" "); } update(data) { this.data = data; this.draw(); } sort(column) { if (!column.contentType) return; let sortFn = null; if (column.contentType === "number") { sortFn = () => this.numericalSort(column.content); } else { sortFn = () => this.alphabeticalSort(column.content); } this.data = [...this.raw].sort(sortFn); this.draw(); } draw() { this.parent .selectAll(".table-head") .data([this.columns]) .join("div") .style("grid-template-columns", this.gridColumns) .selectAll(".header") .data((d) => d) .join("div") .attr("class", "header") .text((d) => d.label); // .on("click", (_, d) => this.sort(d)); const rows = this.table.body .selectAll(".row") .data(this.data, this.key) .join("div") .attr("class", "row") .style("grid-template-columns", this.gridColumns); this.columns.forEach((column) => { const cell = rows .selectAll(`.${column.tag}`) .data((d) => [d]) .join("div") .attr("class", (d) => { return typeof column.content(d) === "number" ? "num" : "text"; }) .classed(column.tag, true) .call(column.contentFn); }); } alphabeticalSort(fn = (d) => d, sortLowToHigh = true) { if (sortLowToHigh) { return (a, b) => fn(a).localeCompare(fn(b)); } return (a, b) => fn(b).localeCompare(fn(a)); } numericalSort(fn = (d) => d, sortLowToHigh = true) { if (sortLowToHigh) { return (a, b) => fn(a) - fn(b); } return (a, b) => fn(b) - fn(a); } } class Column { constructor(label, width, content, contentType, contentFn = (d) => d) { this.label = label; this.width = width; this.content = content; this.contentType = contentType; this.contentFn = contentFn; } get tag() { return this.label.toLowerCase().split(" ").join("-"); } } const toggleActive = (e) => { e.target.parentElement.querySelectorAll("div").forEach((div) => { div.classList.remove("active"); }); e.target.classList.add("active"); }; const makeButton = (buttonDiv, label, fn, defaultActive) => { buttonDiv .append("div") .text(label) .classed("active", defaultActive) .on("click", (e) => { toggleActive(e); fn(); }); }; const partyTagVotes = (fn, type = "votes") => { return (cell) => { const container = cell .selectAll(".party-container") .data(fn) .join("div") .attr("class", "party-container"); container .selectAll(".party-votes") .data((d) => [d]) .join("div") .attr("class", "party-votes num") .text((d) => { if (d && type === "votes") { return d.Votes.toLocaleString(); } else if (d && type === "percent") { return ( d.VotePercent.toLocaleString(undefined, { minimumFractionDigits: 1, }) + "%" ); } return "—"; }); container .selectAll(".party-tag") .data((d) => [d]) .join("div") .attr("class", (d) => `party-tag ${d?.PartyName} bold`) .text((d) => d?.PartyName); }; }; //PERCENTAGE CHANGE const createPercentageChart = (currentElection, previousElection) => { const root = d3.select(".percent-change").html(""); root.append("div").attr("class", "title bold").text(title1); const buttons = root.append("div").attr("class", "button-container"); const tableHead = root .append("div") .attr("class", "table-head") .append("div") .attr("class", "row bold"); tableHead .append("div") .attr("class", "header left") .text("Party") .style("text-align", "right"); tableHead.append("div").attr("class", "header").text(""); tableHead.append("div").attr("class", "header right").text("%"); tableHead.append("div").attr("class", "header right").text("Votes"); const tableBody = root.append("div").attr("class", "table-body"); const createYear = (year, num = 6) => { const data = year.totals.votes.parties.filter((_, i) => i < num); const rows = tableBody .selectAll(".row") .data(data, (d) => d.party) .join("div") .attr("class", "row"); rows .selectAll(".party-name") .data((d) => [d]) .join("div") .attr("class", "party-name") .attr("title", (d) => partyData.find((p) => p.short === d.party).long) .text((d) => d.party); // BaR rows .selectAll(".bar") .data((d) => [d]) .join("div") .attr("class", (d) => `bar ${d.party}`) .style("width", (d) => `${d.percentage * 2.5}%`); rows .selectAll(".party-percentage") .data((d) => [d]) .join("div") .attr("class", "party-percentage num") .text( (d) => d.percentage.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1, }) + "%" ); rows .selectAll(".party-votes") .data((d) => [d]) .join("div") .attr("class", "party-votes num") .text((d) => d.votes.toLocaleString()); }; makeButton(buttons, "2021", () => createYear(currentElection), true); makeButton(buttons, "2019", () => createYear(previousElection)); createYear(currentElection); }; //SEAT COUNT const createSeatChart = (currentElection, previousElection) => { const root = d3.select(".seat-change").html(""); root.append("div").attr("class", "title bold").text(title2); const buttons = root.append("div").attr("class", "button-container"); const tableHead = root .append("div") .attr("class", "table-head") .append("div") .attr("class", "row bold"); tableHead.append("div").text("Party").style("text-align", "right"); tableHead.append("div").text("Seats"); tableHead.append("div").text("Total"); // tableHead.append("div").text("Change"); const tableBody = root.append("div").attr("class", "table-body"); const createYear = (year) => { const rows = tableBody .selectAll(".row") .data(partyData, (d) => d.short) .join("div") .attr("class", "row"); rows .selectAll(".party-name") .data((d) => [d]) .join("div") .attr("class", "party-name") .text((d) => d.short); const seats = rows .selectAll(".party-seats") .data((d) => [d]) .join("div") .attr("class", `party-seats`); seats .selectAll(".seat") .data((d) => year.ridingsWon(d.short)) .join("div") .attr("class", (d) => `seat ${d.Rows[0].PartyName}`); rows .selectAll(".party-total") .data((d) => [year.ridingsWon(d.short)]) .join("div") .attr("class", "party-total num") .text((d) => d.length); }; makeButton(buttons, "2021", () => createYear(currentElection), true); makeButton(buttons, "2019", () => createYear(previousElection)); createYear(currentElection); }; //GAINS AND LOSSES const createGainsLosses = (currentElection, previousElection) => { const changes = currentElection.gainsLosses(previousElection); const data = partyData .map((d) => { return { ...d, change: changes[d.short] }; }) .filter((d) => d.change); const root = d3.select(".gains-losses").html(""); root.append("div").attr("class", "title bold").text(title3); const buttons = root.append("div").attr("class", "button-container"); const tableColumns = [ new Column( "Party", "40px", (d) => d.short, "string", (cell) => cell.text((d) => d.short).classed("party-name", true) ), new Column( "Lost ridings", "minmax(100px, 1fr)", (d) => d, "number", (cell) => { const container = cell .selectAll(".party-seats") .data((d) => [d]) .join("div") .attr("class", "party-seats") .style("justify-content", "flex-end"); container .selectAll(".seat") .data((d) => d.change?.losses || []) .join("div") .attr("class", (d) => `seat ${d.from.PartyName}`) .attr( "title", (d) => `${d.riding.RaceName} (${d.riding.province}) lost to ${d.from.PartyName}` ); } ), new Column( "Gained ridings", "minmax(100px, 1fr)", (d) => d, "number", (cell) => { const container = cell .selectAll(".party-seats") .data((d) => [d]) .join("div") .attr("class", "party-seats"); container .selectAll(".seat") .data((d) => d.change?.gains || []) .join("div") .attr("class", (d) => `seat ${d.from.PartyName}`) .attr( "title", (d) => `${d.riding.RaceName} (${d.riding.province}) gained from ${d.from.PartyName}` ); } ), new Column( "Net", "30px", (d) => d, "number", (cell) => { cell .text((d) => { const change = d.change.gains.length - d.change.losses.length; return `${change > 0 ? "+" : ""}${change}`; }) .classed("num", true); } ), ]; const table = new Table(root, tableColumns, data, (d) => d, {}); }; //REGIONS const createRegions = (currentElection, previousElection) => { const root = d3.select(".regions").html(""); root.append("div").attr("class", "title bold").text(title4); const buttons = root.append("div").attr("class", "button-container"); const data = [ { short: "BC", province: "British Columbia" }, { short: "AB", province: "Alberta" }, { short: "SK", province: "Saskatchewan" }, { short: "MB", province: "Manitoba" }, { short: "ON", province: "Ontario" }, { short: "QC", province: "Quebec" }, { short: "NB", province: "New Brunswick" }, { short: "NS", province: "Nova Scotia" }, { short: "PE", province: "Prince Edward Island" }, { short: "NL", province: "Newfoundland and Labrador" }, { short: "Terr.", province: "Territories" }, /* { short: "YT", province: "Yukon" }, { short: "NT", province: "Northwest Territories" }, { short: "NU", province: "Nunavut" }, */ ]; const dataByYear = (election) => { const copy = [...data]; copy.forEach((d) => { d.ridings = election.ridingsInRegion(d.province); }); return copy; }; const seatsByParty = (party) => { return (cell) => { const container = cell .selectAll(".party-seats") .data((d) => [d.ridings.filter((f) => f.Rows[0].PartyName === party)]) .join("div") .attr("class", "party-seats"); container .selectAll(".seat") .data((d) => d) .join("div") .attr("class", (d) => `seat ${d.Rows[0].PartyName}`) .attr("title", (d) => `${d.RaceName} (${d.province})`) //.style("opacity", (d) => +d.Rows[0].VotePercent / 75); }; }; const tableColumns = [ new Column( "Prov", "28px", (d) => d.short, "string", (cell) => cell .text((d) => d.short) .classed("bold", true) .classed("prov", true) ), new Column("LIB", "4fr", (d) => d.short, "string", seatsByParty("LIB")), new Column("CON", "3fr", (d) => d.short, "string", seatsByParty("CON")), new Column("NDP", "2fr", (d) => d.short, "string", seatsByParty("NDP")), new Column("BQ", "2fr", (d) => d.short, "string", seatsByParty("BQ")), new Column("GRN", "1fr", (d) => d.short, "string", seatsByParty("GRN")), ]; const table = new Table( root, tableColumns, dataByYear(currentElection), (d) => d.short, { striped: true, } ); makeButton( buttons, "2021", () => table.update(dataByYear(currentElection)), true ); makeButton(buttons, "2019", () => table.update(dataByYear(previousElection))); }; //CLOSE RACES const createCloseRaceChart = (currentElection, previousElection) => { const root = d3.select(".margin").html(""); root.append("div").attr("class", "title bold").text(title5); const buttons = root.append("div").attr("class", "button-container"); const marginTableColumns = [ new Column( "Prov", "28px", (d) => d.RaceName, "string", (cell) => cell.text((d) => d.province).classed("bold", true) ), new Column( "Riding", "minmax(100px, 1fr)", (d) => d.RaceName, "string", (cell) => cell.text((d) => d.RaceName) ), new Column( "Winner", "100px", (d) => d.Rows[0], null, partyTagVotes((d) => [d.Rows[0]]) ), new Column( "Runner Up", "100px", (d) => d.Rows[1], null, partyTagVotes((d) => [d.Rows[1]]) ), new Column( "Margin", "70px", (d) => d.margin.percent, "number", (cell) => cell.text( (d) => d.margin.percent.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, }) + "%" ) ), new Column( "Votes", "50px", (d) => d.margin.votes, "number", (cell) => cell.text((d) => d.margin.votes.toLocaleString()) ), ]; const table = new Table( root, marginTableColumns, currentElection.closeRaces(), (d) => d.RaceNbr, { striped: true, maxHeight: "400px" } ); makeButton( buttons, "2021", () => table.update(currentElection.closeRaces()), true ); makeButton(buttons, "2019", () => table.update(previousElection.closeRaces()) ); }; //PARTY RESULTS SORTED BY PERCENTAGE const createBestRidings = (currentElection, previousElection) => { const root = d3.select(".best").html(""); root.append("div").attr("class", "title bold").text(title6); const buttons = root.append("div").attr("class", "button-container"); const dropdown = root .append("select") .attr("class", "bold") .on("change", (e, d) => { state.dropdown = dropdown.property("value"); table.update(state.year.sortByBest(state.dropdown)); }); dropdown .selectAll("option") .data(["LIB", "CON", "NDP", "BQ", "PPC", "GRN"]) .join("option") .attr("value", (d) => d) .text((d) => d); const state = { dropdown: dropdown.property("value"), year: currentElection, }; const tableColumns = [ new Column( "Prov", "28px", (d) => d.RaceName, "string", (cell) => cell.text((d) => d.province).classed("bold", true) ), new Column( "Riding", "minmax(100px, 1fr)", (d) => d.RaceName, "string", (cell) => cell.text((d) => d.RaceName) ), new Column( "Result", "110px", (d) => d.Rows.find((party) => party.PartyName === state.dropdown), null, partyTagVotes( (d) => [ d.Rows.find((candidate) => candidate.PartyName === state.dropdown), ], "percent" ) ), /* new Column( "Rank", "40px", (d) => d.Rows.findIndex((party) => party.PartyName === partyName), "number", (cell) => cell.text( (d) => d.Rows.findIndex((party) => party.PartyName === partyName) + 1 ) ), */ ]; const table = new Table( root, tableColumns, currentElection.sortByBest(state.dropdown), (d) => d.RaceNbr, { striped: true, maxHeight: "400px" } ); makeButton( buttons, "2021", () => { state.dropdown = dropdown.property("value"); state.year = currentElection; table.update(currentElection.sortByBest(state.dropdown)); }, true ); makeButton(buttons, "2019", () => { state.dropdown = dropdown.property("value"); state.year = previousElection; table.update(previousElection.sortByBest(state.dropdown)); }); }; fetchData();