Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions bundle/src/insert/spacefinder/article.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,28 @@ describe('Article Body Adverts', () => {
expect(spaceFillerStub).not.toHaveBeenCalled();
});
});

it('should call relevant functions to fill space on desktop', () => {
const fillAdSlot = jest.fn();
mockViewport(1300, 1300);
return init(fillAdSlot).then(() => {
expect(spaceFillerStub).toHaveBeenCalledTimes(2);
console.log(spaceFillerStub.mock.calls[0]?.[0]);
expect(spaceFillerStub.mock.calls[0]?.[2]?.pass).toEqual('inline1');
expect(spaceFillerStub.mock.calls[1]?.[2]?.pass).toEqual(
'subsequent-inlines',
);
});
});

it('should call relevant functions to fill space on mobile and tablet', () => {
const fillAdSlot = jest.fn();
mockViewport(500, 1300);
return init(fillAdSlot).then(() => {
expect(spaceFillerStub).toHaveBeenCalledTimes(1);
expect(spaceFillerStub.mock.calls[0]?.[2]?.pass).toEqual(
'mobile-inlines',
);
});
});
});
74 changes: 55 additions & 19 deletions bundle/src/insert/spacefinder/article.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { getCurrentBreakpoint } from '../../lib/detect/detect-breakpoint';
import fastdom from '../../lib/fastdom-promise';
import { computeStickyHeights, insertHeightStyles } from '../sticky-inlines';
import { calculateInteractiveGridType } from './interactive';
import { rules } from './rules';
import { spaceFiller } from './space-filler';
import type { SpacefinderWriter } from './spacefinder';
Expand All @@ -21,6 +22,8 @@ const articleBodySelector = '.article-body-commercial-selector';

const isPaidContent = window.guardian.config.page.isPaidContent;

const isInteractive = window.guardian.config.page.contentType === 'Interactive';

/**
* Get the classname for an ad slot container
*
Expand Down Expand Up @@ -126,15 +129,27 @@ const addDesktopInline1 = (fillSlot: FillAdSlot): Promise<boolean> => {

/**
* Inserts all inline ads on desktop except for inline1.
* @param fillSlot a function to call that will fill the slot when each ad slot has been inserted, these could be google display ads or opt opt consentless ads.
* @param isConsentless whether the ads being inserted are consentless ads, this is used to determine the distance from the top of the article that the ad can be placed.
* @param standardArticleGrid whether the article is using the standard article grid, this is used to determine whether to add the offset right class to the ad slot container, which adds a negative margin to push the ad into the right hand column, this isn't needed if the article body is full width
* @param isInteractive whether the article is an interactive, this is used to determine which set of spacefinder rules to use, as we want to be more conservative with ad placement in interactives.
*/
const addDesktopRightRailAds = (
fillSlot: FillAdSlot,
isConsentless: boolean,
): Promise<boolean> => {
const addDesktopRightRailAds = ({
fillSlot,
isConsentless,
standardArticleGrid,
isInteractive,
}: {
fillSlot: FillAdSlot;
isConsentless: boolean;
standardArticleGrid?: boolean;
isInteractive?: boolean;
}): Promise<boolean> => {
const insertAds: SpacefinderWriter = async (paras) => {
const stickyContainerHeights = await computeStickyHeights(
paras,
articleBodySelector,
isInteractive,
);

void insertHeightStyles(
Expand All @@ -147,9 +162,13 @@ const addDesktopRightRailAds = (
const slots = paras.slice(0, paras.length).map(async (para, i) => {
const isLastInline = i === paras.length - 1;

const containerClasses =
getStickyContainerClassname(i) +
' offset-right ad-slot--offset-right ad-slot-container--offset-right';
const containerClasses = [
getStickyContainerClassname(i),
'ad-slot-container--right-column', // float the ad to the right and sets max width and transparent background https://github.com/guardian/dotcom-rendering/blob/main/dotcom-rendering/src/lib/adStyles.ts#L161
standardArticleGrid && 'ad-slot-container--offset-right', // adds a negative margin to push the ad into the right rail, this isn't needed if the article body is full width
]
.filter(Boolean)
.join(' ');

const containerOptions = {
sticky: true,
Expand Down Expand Up @@ -180,15 +199,15 @@ const addDesktopRightRailAds = (
await Promise.all(slots);
};

return spaceFiller.fillSpace(
rules.desktopRightRail(isConsentless),
insertAds,
{
waitForImages: true,
waitForInteractives: true,
pass: 'subsequent-inlines',
},
);
const rightRailRules = isInteractive
? rules.interactiveRightRail
: rules.desktopRightRail(isConsentless);

return spaceFiller.fillSpace(rightRailRules, insertAds, {
waitForImages: true,
waitForInteractives: true,
pass: 'subsequent-inlines',
});
};

const additionalMobileAndTabletInlineSizes = (index: number) => {
Expand Down Expand Up @@ -251,7 +270,7 @@ const addMobileAndTabletInlineAds = (
* @param fillSlot A function to call that will fill the slot when each ad slot has been inserted,
* these could be google display ads or opt opt consentless ads.
*/
const addInlineAds = (
const addInlineAds = async (
fillSlot: FillAdSlot,
isConsentless: boolean,
): Promise<boolean> => {
Expand All @@ -260,12 +279,29 @@ const addInlineAds = (
return addMobileAndTabletInlineAds(fillSlot, currentBreakpoint);
}

if (isInteractive) {
const interactiveGridType = await calculateInteractiveGridType();

if (interactiveGridType === 'unknown') {
return Promise.resolve(false);
}

return addDesktopInline1(fillSlot).then(() =>
addDesktopRightRailAds({
fillSlot,
isConsentless,
standardArticleGrid: interactiveGridType === 'standard',
isInteractive: true,
}),
);
}

if (isPaidContent) {
return addDesktopRightRailAds(fillSlot, isConsentless);
return addDesktopRightRailAds({ fillSlot, isConsentless });
}

return addDesktopInline1(fillSlot).then(() =>
addDesktopRightRailAds(fillSlot, isConsentless),
addDesktopRightRailAds({ fillSlot, isConsentless }),
);
};

Expand Down
65 changes: 65 additions & 0 deletions bundle/src/insert/spacefinder/interactive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import fastdom from '../../lib/fastdom-promise';

/**
* Get the widths of the interactive grid, the grid body and the viewport. We need these to determine how to position ads within interactives, for example whether we need to add an offset class to move the ad over to the right.
*/
const getInteractiveGridWidths = () =>
fastdom.measure(() => {
const contentGridElement = document.querySelector<HTMLElement>(
'.content--interactive-grid',
);
const bodyElement = contentGridElement?.querySelector<HTMLElement>(
'[data-gu-name="body"]',
);

const contentGridWidth =
contentGridElement?.getBoundingClientRect().width;
const bodyWidth = bodyElement?.getBoundingClientRect().width;
const viewportWidth = window.innerWidth;

return { contentGridWidth, bodyWidth, viewportWidth };
});

/**
* Determine whether the grid body is the full width of teh content grid. If the grid body is full width, then we need to insert the ad without the offset right class, otherwise the ad will be pushed too far into the right hand column and could end up outside of the viewport.
*/
const isBodyFullWidthOfGrid = (
bodyWidth: number,
contentGridWidth: number,
): boolean => {
return bodyWidth >= contentGridWidth;
};

/**
* Determine whether the grid body is the full width of the viewport. If the grid body is the full width of the viewport, then it's unlikely to have a right hand column, even if it does, it's probably using wacky styles that we can't easily work with, so we won't attempt to insert ads in this case.
**/
const isBodyFullWidthOfViewport = (
bodyWidth: number,
viewportWidth: number,
): boolean => {
return bodyWidth >= viewportWidth;
};

/**
* Calculate the grid type of the interactive. We need to know the grid type to determine how to position right rail ads within interactives, for example whether we need to add an offset class to move the ad over to the right.
*/
const calculateInteractiveGridType = async (): Promise<
'standard' | 'full-width' | 'unknown'
> => {
const { bodyWidth, contentGridWidth, viewportWidth } =
await getInteractiveGridWidths();

if (bodyWidth && contentGridWidth && viewportWidth) {
// If the grid body is the full width of the viewport, then it's unlikely to have a right hand column, even if it does, it's probably using wacky styles that we can't easily work with, so we won't attempt to insert ads in this case.
if (isBodyFullWidthOfViewport(bodyWidth, viewportWidth)) {
return 'unknown';
}

const isFullWidth = isBodyFullWidthOfGrid(bodyWidth, contentGridWidth);

return isFullWidth ? 'full-width' : 'standard';
}
return 'unknown';
};

export { calculateInteractiveGridType };
33 changes: 33 additions & 0 deletions bundle/src/insert/spacefinder/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,38 @@ const desktopRightRail = (isConsentless: boolean): SpacefinderRules => {
};
};

const interactiveRightRail: SpacefinderRules = {
bodySelector,
candidateSelector,
minDistanceFromTop: 250,
minDistanceFromBottom: 300,
opponentSelectorRules: {
[adSlotContainerSelector]: {
marginBottom: 500,
marginTop: 500,
},
// we want to be more conservative with ad placement in interactives, anything that isn't a paragraph is an opponent
[':scope > *:not(p)']: {
marginBottom: 0,
marginTop: 600,
},
},
/**
* Filter out any candidates that are too close to the last winner
* see https://github.com/guardian/commercial/tree/main/docs/spacefinder#avoiding-other-winning-candidates
* for more information
**/
filter: (candidate, lastWinner) => {
if (!lastWinner) {
return true;
}
const largestSizeForSlot = adSizes.halfPage.height;
const distanceBetweenAds =
candidate.top - lastWinner.top - largestSizeForSlot;
return distanceBetweenAds >= minDistanceBetweenRightRailAds;
},
};

const mobileMinDistanceFromArticleTop = 200;

const mobileCandidateSelector =
Expand Down Expand Up @@ -213,5 +245,6 @@ const mobileAndTabletInlines: SpacefinderRules = {
export const rules = {
desktopInline1,
desktopRightRail,
interactiveRightRail,
mobileAndTabletInlines,
};
10 changes: 7 additions & 3 deletions bundle/src/insert/sticky-inlines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,22 @@ const insertHeightStyles = (
const computeStickyHeights = async (
winners: HTMLElement[],
articleBodySelector: string,
isInteractive = false,
): Promise<number[]> => {
// Immersive figures can extend into the right column
// Therefore we have to take them into account when we can compute how far an ad can be sticky for
const immersiveFigures = [
const opponentsToAvoid = [
...document.querySelectorAll<HTMLElement>(
'[data-spacefinder-role="immersive"]',
isInteractive
? // interactives occasionally incorrectly apply showcase to elements that should be immersive, so we need to check for both roles
'.article-body-commercial-selector > *:not(p)'
: '[data-spacefinder-role="immersive"]',
),
];

const { figures, winningParas, articleBodyElementHeightBottom } =
await fastdom.measure(() => {
const figures: RightColItem[] = immersiveFigures.map((element) => ({
const figures: RightColItem[] = opponentsToAvoid.map((element) => ({
kind: 'figure',
top: element.getBoundingClientRect().top,
element,
Expand Down
4 changes: 1 addition & 3 deletions bundle/src/lib/commercial-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,7 @@ class CommercialFeatures {
isUserInTestGroup(
'commercial-enable-spacefinder-on-interactives',
'true',
) &&
getCurrentBreakpoint() === 'mobile' &&
isInteractive;
) && isInteractive;

const enableArticleBodyAdverts =
isArticle || isInAdsInInteractivesOnMobileTest;
Expand Down