From cfb3dc54c7642771542488e9a4884e26403de232 Mon Sep 17 00:00:00 2001 From: Matt Godbolt Date: Sun, 13 Nov 2022 10:35:39 -0600 Subject: [PATCH] Sponsorship changes (#4274) Rotate sponsors on the top of the page: - `topIcon` -> `topIconShowEvery` - `Sponsors` becomes an interface with methods to get levels, pick sponsors etc - A somewhat acceptable algorithm for generating a "fair choice" of sponsors given the constraints: - "show at least 1 in N times" - don't unfairly show one sponsor more than any other at the same level - Sponsor icons are loaded dynamically via a "bits" handler (introducing a short delay in them appearing), but this means the index.html doesn't change on every load, so it's still cacheable --- etc/config/sponsors.yaml | 7 +- lib/sponsors.interfaces.ts | 13 ++-- lib/sponsors.ts | 109 ++++++++++++++++++++++++++++-- static/main.js | 8 +++ test/sponsors-test.js | 116 +++++++++++++++++++++++++++----- views/bits/icons.pug | 6 ++ views/bits/sponsors-content.pug | 2 +- views/bits/sponsors.pug | 2 +- views/sponsor-icons.pug | 16 ++--- 9 files changed, 238 insertions(+), 41 deletions(-) create mode 100644 views/bits/icons.pug diff --git a/etc/config/sponsors.yaml b/etc/config/sponsors.yaml index 96cf29c01..4d73b46e0 100644 --- a/etc/config/sponsors.yaml +++ b/etc/config/sponsors.yaml @@ -1,4 +1,5 @@ --- +maxTopIcons: 3 levels: - name: Corporate Sponsors description: 'Enormous thanks to our corporate sponsors. Please visit their websites below:' @@ -9,7 +10,7 @@ levels: img: https://static.ce-cdn.net/SOLID+SANDS-LOGO-RGB-500px.png url: https://solidsands.com/ priority: 100 - topIcon: true + topIconShowEvery: 3 statsId: solid_sands - name: Intel description: We engineer solutions for our customers’ greatest challenges with reliable, cloud to edge computing, inspired by Moore’s Law. @@ -17,7 +18,7 @@ levels: img: https://static.ce-cdn.net/intel/logo-classicblue-3000px.png url: https://intel.com/ priority: 400 - topIcon: true + topIconShowEvery: 1 sideBySide: true statsId: intel - name: Backtrace @@ -29,7 +30,7 @@ levels: img: https://static.ce-cdn.net/bt/BT-logo (1).png url: https://backtrace.io/sign-up?utm_source=website&utm_medium=paiddisplay&utm_campaign=00O8Y000008JiuiUAC&utm_content=godboltbannerad priority: 500 - topIcon: true + topIconShowEvery: 3 sideBySide: true statsId: backtrace - name: Patreon Legends diff --git a/lib/sponsors.interfaces.ts b/lib/sponsors.interfaces.ts index d0904a375..e6665d0cb 100644 --- a/lib/sponsors.interfaces.ts +++ b/lib/sponsors.interfaces.ts @@ -31,7 +31,7 @@ export type Sponsor = { url?: string; onclick: string; priority: number; - topIcon: boolean; + topIconShowEvery: number; sideBySide: boolean; statsId?: string; }; @@ -43,7 +43,10 @@ export type Level = { sponsors: Sponsor[]; }; -export type Sponsors = { - icons: Sponsor[]; - levels: Level[]; -}; +export interface Sponsors { + getLevels(): Level[]; + + pickTopIcons(): Sponsor[]; + + getAllTopIcons(): Sponsor[]; +} diff --git a/lib/sponsors.ts b/lib/sponsors.ts index feaafcb34..c63bbc5d5 100644 --- a/lib/sponsors.ts +++ b/lib/sponsors.ts @@ -24,7 +24,7 @@ import yaml from 'yaml'; -import {Sponsor, Sponsors} from './sponsors.interfaces'; +import {Level, Sponsor, Sponsors} from './sponsors.interfaces'; export function parse(mapOrString: Record | string): Sponsor { if (typeof mapOrString == 'string') mapOrString = {name: mapOrString}; @@ -36,7 +36,7 @@ export function parse(mapOrString: Record | string): Sponsor { img: mapOrString.img, icon: mapOrString.icon || mapOrString.img, icon_dark: mapOrString.icon_dark, - topIcon: !!mapOrString.topIcon, + topIconShowEvery: mapOrString.topIconShowEvery || 0, sideBySide: !!mapOrString.sideBySide, priority: mapOrString.priority || 0, statsId: mapOrString.statsId, @@ -50,14 +50,113 @@ function compareSponsors(lhs: Sponsor, rhs: Sponsor): number { return lhs.name.localeCompare(rhs.name); } +function calcMean(values: number[]): number { + return values.reduce((x, y) => x + y, 0) / values.length; +} + +function squareSumFromMean(values: number[]): number { + const mean = calcMean(values); + return values.reduce((x, y) => x + (y - mean) * (y - mean), 0); +} + +function standardDeviation(values: number[]): number { + return values.length < 2 ? 0 : Math.sqrt(squareSumFromMean(values) / (values.length - 1)); +} + +// A sponsor icon set is ok if: +// - each sponsor is shown at least every "topIconShowEvery" +// - the standard deviation for the number of showings between sponsors at the same "show every' is not too high: that +// is we fairly distribute showings of sponsors at the same level +function sponsorIconSetsOk( + sponsorAppearanceCount: Map, + totalAppearances: number, + maxStandardDeviation: number, +): boolean { + const countsByShowEvery: Map = new Map(); + for (const [icon, count] of sponsorAppearanceCount.entries()) { + const seenEvery = count > 0 ? totalAppearances / count : Infinity; + if (seenEvery > icon.topIconShowEvery) { + return false; + } + const others = countsByShowEvery.get(icon.topIconShowEvery) || []; + others.push(seenEvery); + countsByShowEvery.set(icon.topIconShowEvery, others); + } + return Math.max(...[...countsByShowEvery.values()].map(standardDeviation)) <= maxStandardDeviation; +} + +export function makeIconSets( + icons: Sponsor[], + maxIcons: number, + maxIters = 100, + maxStandardDeviation = 0.5, +): Sponsor[][] { + const result: Sponsor[][] = []; + const sponsorAppearanceCount: Map = new Map(); + for (const icon of icons) sponsorAppearanceCount.set(icon, 0); + while (!sponsorIconSetsOk(sponsorAppearanceCount, result.length, maxStandardDeviation)) { + if (result.length > maxIters) { + throw new Error(`Unable to find a solution in ${maxIters}`); + } + const toPick = icons.map(icon => { + return { + icon: icon, + // Number of times we'd expect to see this, divided by number of times we saw it + error: result.length / icon.topIconShowEvery / (sponsorAppearanceCount.get(icon) || 0.00001), + }; + }); + toPick.sort((lhs, rhs) => rhs.error - lhs.error); + const chosen = toPick + .slice(0, maxIcons) + .map(x => x.icon) + .sort(compareSponsors); + for (const c of chosen) sponsorAppearanceCount.set(c, (sponsorAppearanceCount.get(c) || 0) + 1); + result.push(chosen); + } + return result; +} + +class SponsorsImpl implements Sponsors { + private readonly _levels: Level[]; + private readonly _icons: Sponsor[]; + private readonly _iconSets: Sponsor[][]; + private _nextSet: number; + + constructor(levels: Level[], maxTopIcons) { + this._levels = levels; + this._icons = []; + for (const level of levels) { + this._icons.push(...level.sponsors.filter(sponsor => sponsor.topIconShowEvery && sponsor.icon)); + } + this._iconSets = makeIconSets(this._icons, maxTopIcons); + this._nextSet = 0; + } + + getLevels(): Level[] { + return this._levels; + } + + getAllTopIcons(): Sponsor[] { + return this._icons; + } + + pickTopIcons(): Sponsor[] { + const result = this._iconSets[this._nextSet]; + this._nextSet = (this._nextSet + 1) % this._iconSets.length; + return result; + } +} + +export function loadSponsorsFromLevels(levels: Level[], maxTopIcons: number): Sponsors { + return new SponsorsImpl(levels, maxTopIcons); +} + export function loadSponsorsFromString(stringConfig: string): Sponsors { const sponsorConfig = yaml.parse(stringConfig); - sponsorConfig.icons = []; for (const level of sponsorConfig.levels) { for (const required of ['name', 'description', 'sponsors']) if (!level[required]) throw new Error(`Level is missing '${required}'`); level.sponsors = level.sponsors.map(parse).sort(compareSponsors); - sponsorConfig.icons.push(...level.sponsors.filter(sponsor => sponsor.topIcon && sponsor.icon)); } - return sponsorConfig; + return loadSponsorsFromLevels(sponsorConfig.levels, sponsorConfig.maxTopIcons || 3); } diff --git a/static/main.js b/static/main.js index 836cb410e..02bc7e3e6 100644 --- a/static/main.js +++ b/static/main.js @@ -196,6 +196,14 @@ function setupButtons(options, hub) { alertSystem.alert('Changelog', $(require('./generated/changelog.pug').default.text)); }); + $.get(window.location.origin + window.httpRoot + 'bits/icons.html') + .done(function (data) { + $('#ces .ces-icons').html(data); + }) + .fail(function (err) { + Sentry.captureException(err); + }); + $('#ces').on('click', function () { $.get(window.location.origin + window.httpRoot + 'bits/sponsors.html') .done(function (data) { diff --git a/test/sponsors-test.js b/test/sponsors-test.js index 84e67ae25..2c7bbfc9d 100644 --- a/test/sponsors-test.js +++ b/test/sponsors-test.js @@ -22,9 +22,11 @@ // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // POSSIBILITY OF SUCH DAMAGE. -import {loadSponsorsFromString, parse} from '../lib/sponsors'; +import fs from 'fs'; -import {should} from './utils'; +import {loadSponsorsFromString, makeIconSets, parse} from '../lib/sponsors'; + +import {resolvePathFromTestRoot, should} from './utils'; describe('Sponsors', () => { it('should expand names to objects', () => { @@ -41,7 +43,7 @@ describe('Sponsors', () => { should.equal(obj.img, undefined); should.equal(obj.icon, undefined); should.equal(obj.icon_dark, undefined); - obj.topIcon.should.be.false; + obj.topIconShowEvery.should.eq(0); obj.sideBySide.should.be.false; should.equal(obj.statsId, undefined); }); @@ -64,7 +66,7 @@ describe('Sponsors', () => { parse({name: 'bob', icon: 'icon', icon_dark: 'icon_dark'}).icon_dark.should.eq('icon_dark'); }); it('should handle topIcons', () => { - parse({name: 'bob', topIcon: true}).topIcon.should.be.true; + parse({name: 'bob', topIconShowEvery: 2}).topIconShowEvery.should.eq(2); }); it('should handle clicks', () => { parse({ @@ -92,9 +94,10 @@ levels: - Yay `); sample.should.not.be.null; - sample.levels.length.should.eq(2); - sample.levels[0].name.should.eq('Patreon Legends'); - sample.levels[1].name.should.eq('Patreons'); + const levels = sample.getLevels(); + levels.length.should.eq(2); + levels[0].name.should.eq('Patreon Legends'); + levels[1].name.should.eq('Patreons'); }); it('should sort sponsors by name', () => { @@ -108,7 +111,7 @@ levels: - C - A - B - `).levels[0].sponsors; + `).getLevels()[0].sponsors; peeps.map(sponsor => sponsor.name).should.deep.equals(['A', 'B', 'C', 'D']); }); it('should sort sponsors by priority then name', () => { @@ -124,7 +127,7 @@ levels: priority: 50 - name: B priority: 50 - `).levels[0].sponsors; + `).getLevels()[0].sponsors; peeps .map(sponsor => { return {name: sponsor.name, priority: sponsor.priority}; @@ -136,7 +139,7 @@ levels: ]); }); - it('should pick out the top level icons', () => { + it('should pick out all the top level icons', () => { const icons = loadSponsorsFromString(` --- levels: @@ -145,7 +148,7 @@ levels: sponsors: - name: one img: pick_me - topIcon: true + topIconShowEvery: 1 - name: two img: not_me - name: another level @@ -153,13 +156,96 @@ levels: sponsors: - name: three img: not_me_either - topIcon: false + topIconShowEvery: 0 - name: four img: pick_me_also - topIcon: true + topIconShowEvery: 2 - name: five - topIcon: true - `).icons; + topIconShowEvery: 3 + `).getAllTopIcons(); icons.map(s => s.name).should.deep.equals(['one', 'four']); }); + + it('should pick icons appropriately when all required every 3', () => { + const sponsor1 = parse({name: 'Sponsor1', topIconShowEvery: 3, icon: '1'}); + const sponsor2 = parse({name: 'Sponsor2', topIconShowEvery: 3, icon: '2'}); + const sponsor3 = parse({name: 'Sponsor3', topIconShowEvery: 3, icon: '3'}); + const icons = [sponsor1, sponsor2, sponsor3]; + makeIconSets(icons, 10).should.deep.eq([icons]); + makeIconSets(icons, 3).should.deep.eq([icons]); + makeIconSets(icons, 2).should.deep.eq([ + [sponsor1, sponsor2], + [sponsor1, sponsor3], + [sponsor2, sponsor3], + ]); + makeIconSets(icons, 1).should.deep.eq([[sponsor1], [sponsor2], [sponsor3]]); + }); + it('should pick icons appropriately when not required on different schedules', () => { + const sponsor1 = parse({name: 'Sponsor1', topIconShowEvery: 1, icon: '1'}); + const sponsor2 = parse({name: 'Sponsor2', topIconShowEvery: 2, icon: '2'}); + const sponsor3 = parse({name: 'Sponsor3', topIconShowEvery: 3, icon: '3'}); + const icons = [sponsor1, sponsor2, sponsor3]; + makeIconSets(icons, 10).should.deep.eq([icons]); + makeIconSets(icons, 3).should.deep.eq([icons]); + makeIconSets(icons, 2).should.deep.eq([ + [sponsor1, sponsor2], + [sponsor1, sponsor3], + ]); + (() => makeIconSets(icons, 1)).should.throw(); + }); + it('should pick icons appropriately with a lot of sponsors on representative schedules', () => { + const sponsor1 = parse({name: 'Sponsor1', topIconShowEvery: 1, icon: '1'}); + const sponsor2 = parse({name: 'Sponsor2', topIconShowEvery: 3, icon: '2'}); + const sponsor3 = parse({name: 'Sponsor3', topIconShowEvery: 3, icon: '3'}); + const sponsor4 = parse({name: 'Sponsor4', topIconShowEvery: 3, icon: '3'}); + const sponsor5 = parse({name: 'Sponsor5', topIconShowEvery: 3, icon: '3'}); + const icons = [sponsor1, sponsor2, sponsor3, sponsor4, sponsor5]; + makeIconSets(icons, 10).should.deep.eq([icons]); + makeIconSets(icons, 3).should.deep.eq([ + [sponsor1, sponsor2, sponsor3], + [sponsor1, sponsor4, sponsor5], + ]); + (() => makeIconSets(icons, 1)).should.throw(); + }); + it('should handle alternating', () => { + const sponsor1 = parse({name: 'Sponsor1', topIconShowEvery: 1, icon: '1'}); + const sponsor2 = parse({name: 'Sponsor2', topIconShowEvery: 1, icon: '2'}); + const sponsor3 = parse({name: 'Sponsor3', topIconShowEvery: 2, icon: '3'}); + const sponsor4 = parse({name: 'Sponsor4', topIconShowEvery: 2, icon: '4'}); + const icons = [sponsor1, sponsor2, sponsor3, sponsor4]; + makeIconSets(icons, 4).should.deep.eq([icons]); + makeIconSets(icons, 3).should.deep.eq([ + [sponsor1, sponsor2, sponsor3], + [sponsor1, sponsor2, sponsor4], + ]); + (() => makeIconSets(icons, 2)).should.throw(); + }); +}); + +describe('Our specific sponsor file', () => { + const stringConfig = fs.readFileSync(resolvePathFromTestRoot('../etc/config/sponsors.yaml')).toString(); + it('should parse the current config', () => { + loadSponsorsFromString(stringConfig); + }); + it('should pick appropriate sponsor icons', () => { + const numLoads = 100; + const expectedNumIcons = 3; + + const sponsors = loadSponsorsFromString(stringConfig); + const picks = []; + for (let load = 0; load < numLoads; ++load) { + picks.push(sponsors.pickTopIcons()); + } + const countBySponsor = new Map(); + for (const pick of picks) { + for (const sponsor of pick) { + countBySponsor.set(sponsor, (countBySponsor.get(sponsor) || 0) + 1); + } + pick.length.should.eq(expectedNumIcons); + } + for (const topIcon of sponsors.getAllTopIcons()) { + const appearsEvery = countBySponsor.get(topIcon) / numLoads; + appearsEvery.should.lte(topIcon.topIconShowEvery); + } + }); }); diff --git a/views/bits/icons.pug b/views/bits/icons.pug new file mode 100644 index 000000000..b2c27be19 --- /dev/null +++ b/views/bits/icons.pug @@ -0,0 +1,6 @@ +each icon in sponsors.pickTopIcons() + if !icon.icon_dark || icon.icon === icon.icon_dark + img.ces-icon(src=icon.icon alt=icon.name data-statsid=icon.statsId) + else + img.ces-icon.theme-light-only(src=icon.icon alt=icon.name data-statsid=icon.statsId) + img.ces-icon.theme-dark-only(src=icon.icon_dark alt=icon.name data-statsid=icon.statsId) diff --git a/views/bits/sponsors-content.pug b/views/bits/sponsors-content.pug index f3ba78662..647aee4e8 100644 --- a/views/bits/sponsors-content.pug +++ b/views/bits/sponsors-content.pug @@ -18,7 +18,7 @@ block content a(href="mailto:matt@godbolt.org") email Matt |  for corporate sponsorship. .mt-6 - each level, index in sponsors.levels + each level, index in sponsors.getLevels() .ces-item-block(class=level.class id="ces_hop_" + index) h2.ces-item-name= level.name div.ces-item-description= level.description diff --git a/views/bits/sponsors.pug b/views/bits/sponsors.pug index 70a09741e..950b46466 100644 --- a/views/bits/sponsors.pug +++ b/views/bits/sponsors.pug @@ -1,6 +1,6 @@ div.ces-level-selectors.d-flex.justify-content-center.mt-3 div.btn-group.btn-group - each level, index in sponsors.levels + each level, index in sponsors.getLevels() button.btn.btn-primary(onclick='document.getElementById("ces_hop_' + index + '").scrollIntoView({behavior:"smooth"});')=level.name block content diff --git a/views/sponsor-icons.pug b/views/sponsor-icons.pug index cf1dda143..b0f4e64a2 100644 --- a/views/sponsor-icons.pug +++ b/views/sponsor-icons.pug @@ -1,12 +1,6 @@ .ces-icons - each icon in sponsors.icons - if noscript - a(href=icon.url) - img.ces-icon(src=icon.icon alt=icon.name data-statsid=icon.statsId) - span   - else - if !icon.icon_dark || icon.icon === icon.icon_dark - img.ces-icon(src=icon.icon alt=icon.name data-statsid=icon.statsId) - else - img.ces-icon.theme-light-only(src=icon.icon alt=icon.name data-statsid=icon.statsId) - img.ces-icon.theme-dark-only(src=icon.icon_dark alt=icon.name data-statsid=icon.statsId) + if noscript + each icon in sponsors.pickTopIcons() + a(href=icon.url) + img.ces-icon(src=icon.icon alt=icon.name data-statsid=icon.statsId) + span