/* ============ BUSINESS — categorized works marquee (replaces old Intro slot) ============
Each lane pulls works whose `cat` tags match the lane's tag list. A lane only scrolls
when its items actually overflow the viewport; if they fit, it stays static. */
const BUSINESS_LANES = [
{ label: "システム開発", tags: ["システム開発"], dir: "rtl" },
{ label: "イベント予約や決済アプリ開発", tags: ["イベント予約", "決済"], dir: "ltr" },
{ label: "寮のアプリ開発", tags: ["寮管理", "寮アプリ"], dir: "rtl" },
];
/* DeviceFrame's phone/hardware/tablet/doc variants are fixed-px in demo.css already;
only "desktop" uses width:100% internally, so it alone needs an explicit parent width. */
function laneFrameStyle(device) {
return device === "desktop" ? { width: 380 } : undefined;
}
/* Real demo screens skip DeviceFrame (see below) and instead sit in a work-thumb box —
give that box the device's own aspect ratio so it hugs the mockup tightly instead of
centering it inside a generic wide 4:3 frame with dead space (and a mismatched hover area). */
const LANE_DEMO_ASPECT = { phone: "300/620", tablet: "4/3", desktop: "16/10", hardware: "5/3", doc: "1/1.41" };
function laneDemoStyle(device) {
return { aspectRatio: LANE_DEMO_ASPECT[device] || LANE_DEMO_ASPECT.phone };
}
/* Backdrop thumbnails are a plain cropped screenshot, not the full WorkPrototypeThumb —
that component draws the same thick phone-bezel chrome used for the big grid cards,
which is way oversized for a 132px strip thumb (fat border, huge corner radius,
spilling past the box). A flat cover-fit image crops cleanly to the tiny frame. */
function LaneBackVisual({ w, idx }) {
const cfg = w.demoConfig || {};
const screens = w.demo && Array.isArray(cfg.screens) ? orderScreens(cfg.screens, cfg.initial) : [];
const shot = screens[0] && screens[0].image;
if (shot) return
;
return ;
}
/* One "featured" work gets the big device mockup up front; the rest of the lane's
works pass by as flat thumbnails in a backdrop strip behind it. The strip always
sweeps the FULL width of the stage — entering from one edge, crossing past the
other, then looping — regardless of item count. The classic "double the content,
shift by half its own width" filmstrip loop only reads as motion once there's
enough content to overflow the stage; with just one or two items it stays parked
near the stage's left edge — right where the feature mockup already sits — so it
never visibly clears it or crosses the open space beside it. */
function Lane({ label, works, dir, alt, onFocusWork }) {
const viewportRef = useRef(null);
const trackRef = useRef(null);
const [travel, setTravel] = useState({ start: 0, end: 0, duration: 22 });
const feature = works[0];
const backdrop = works.slice(1);
const hasBackdrop = backdrop.length > 0;
useLayoutEffect(() => {
const vp = viewportRef.current, track = trackRef.current;
if (!vp || !track || !hasBackdrop) return;
const speedPxPerSec = 40;
const measure = () => {
const containerW = vp.clientWidth;
const contentW = track.scrollWidth;
setTravel({ start: containerW, end: -contentW, duration: Math.max(8, (containerW + contentW) / speedPxPerSec) });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(vp);
ro.observe(track);
return () => ro.disconnect();
}, [hasBackdrop, backdrop.length]);
if (!works.length) {
return (
);
}
const open = (id) => onFocusWork(id);
const keyOpen = (e, id) => { if (e.key === "Enter") onFocusWork(id); };
return (
{hasBackdrop && (
{backdrop.map((w, i) => (
open(w.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => keyOpen(e, w.id)}
>
))}
)}
open(feature.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => keyOpen(e, feature.id)}
>
{feature.demo ? (
/* WorkPrototypeThumb already draws its own phone/tablet/desktop chrome around
real demo screens — wrapping it in DeviceFrame here would double it up, so it
just gets the same work-thumb positioning box the Works grid cards use. */
) : (
)}
{label}
{feature.title}{feature.en}
{String(works.length).padStart(2, "0")} WORKS
);
}
function BusinessSection({ onFocusWork }) {
const all = (window.HASHBIT_DATA && window.HASHBIT_DATA.works) || [];
const lanes = BUSINESS_LANES.map(l => ({
...l,
works: all.filter(w => (w.cat || []).some(c => l.tags.includes(c)))
}));
return (
02 Business
HASHBIT / 事業内容
{lanes.map((l, i) => (
{i > 0 && }
))}
);
}
window.BusinessSection = BusinessSection;